diff --git a/.agent/skills/add-converter/SKILL.md b/.agent/skills/add-converter/SKILL.md deleted file mode 100644 index fe30a9a..0000000 --- a/.agent/skills/add-converter/SKILL.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -name: add-converter -description: >- - Add a new resource type converter to acplugin (e.g., adding support for - converting a new Claude Code resource type) ---- - -# 添加新资源类型转换器 - -当需要支持转换新的 Claude Code 资源类型时,按以下步骤操作。 - -## 步骤 - -### 1. 定义类型 (`src/types.ts`) - -添加新资源的接口定义和 frontmatter 类型(如果有),以及在 `ScanResult` 中添加字段。在 `ConvertedFile.type` 联合类型中添加新值。 - -### 2. 添加扫描函数 (`src/scanner/claude.ts`) - -创建并导出可复用的扫描函数(如 `scanXxxDir()`),这样 `plugin.ts` 也能使用。 - -在 `scanClaudeProject()` 中调用新函数。 - -### 3. 集成 Plugin Scanner (`src/scanner/plugin.ts`) - -在 `scanPlugin()` 中调用新扫描函数,注意 plugin 目录结构与 .claude/ 不同: -- 项目: `.claude/xxx/` -- Plugin: `xxx/`(直接在 plugin 根目录下) - -更新 `countResources()` 包含新资源。 - -### 4. 创建 Converter (`src/converter/xxx.ts`) - -实现 `convertXxx(item, platform)` 函数,处理三个平台: - -```typescript -export function convertXxx(item: Xxx, platform: Platform): ConvertedFile { - switch (platform) { - case 'codex': return convertToCodex(item); - case 'opencode': return convertToOpenCode(item); - case 'cursor': return convertToCursor(item); - } -} -``` - -**关键原则**: -- Converter 无副作用,只返回 `ConvertedFile` -- 不支持的功能用降级策略(合并到 AGENTS.md 或 rules) -- 返回 warnings 告知用户不兼容项 - -### 5. 集成 Writer (`src/writer/*.ts`) - -在三个 writer 文件中调用新 converter,处理合并逻辑。 - -### 6. 更新 CLI 输出 (`src/index.ts`) - -更新 `printScanResult()` 和 `convertSingleScan()` 中的资源计数。 - -### 7. 添加测试 (`src/__tests__/xxx.test.ts`) - -为新 converter 创建测试,覆盖三个平台的转换逻辑。 - -### 8. 更新 test-fixture/ - -在 `test-fixture/` 中添加新资源类型的示例文件,确保 `scanner.test.ts` 覆盖。 - -## Frontmatter 解析容错 - -社区插件的 YAML 可能格式不规范。扫描函数中必须 try-catch `parseFrontmatter()`,解析失败时用空 frontmatter + 原始内容兜底。 diff --git a/.agent/skills/add-platform/SKILL.md b/.agent/skills/add-platform/SKILL.md deleted file mode 100644 index 3abaee1..0000000 --- a/.agent/skills/add-platform/SKILL.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -name: add-platform -description: 'Add support for a new target platform to acplugin (e.g., Windsurf, Zed, etc.)' ---- - -# 添加新目标平台 - -当需要支持新的 AI 编程工具作为转换目标时,按以下步骤操作。 - -## 前置调研 - -1. 了解目标平台的配置格式: - - Skills/技能文件格式和路径 - - 自定义指令文件(类似 CLAUDE.md / AGENTS.md) - - MCP 服务器配置格式 - - Agent 定义方式(如果有) - - 命令/斜杠命令格式 - - Hooks 系统(如果有) - -2. 确认格式差异和降级策略 - -## 实施步骤 - -### 1. 类型注册 (`src/types.ts`) - -在 `Platform` 联合类型中添加新值: -```typescript -export type Platform = 'codex' | 'opencode' | 'cursor' | 'newplatform'; -``` - -### 2. 每个 Converter 添加分支 - -在所有 `src/converter/*.ts` 文件中,给 `switch (platform)` 添加新的 case。 - -参考现有平台的转换逻辑,特别关注: -- **路径映射**:新平台的目录结构 -- **Frontmatter 差异**:新平台是否需要特殊字段 -- **降级策略**:不支持的功能如何处理 - -### 3. 创建 Writer (`src/writer/newplatform.ts`) - -复制 `cursor.ts` 作为模板,修改平台名: -```typescript -export function generateNewPlatform(scan: ScanResult): ConvertResult { ... } -``` - -### 4. CLI 注册 (`src/index.ts`) - -- `generateForPlatform()` 添加新 case -- `validPlatforms` 数组添加新值 -- import 新 writer - -### 5. TUI 注册 (`src/tui.ts`) - -在 `selectPlatforms()` 的 choices 中添加新选项。 - -### 6. 测试 - -- 每个 converter 测试文件添加新平台的用例 -- 新增 `src/__tests__/newplatform-writer.test.ts`(可选) - -### 7. 文档 - -- 更新 README.md 和 README.zh-CN.md 的支持矩阵表格 -- 更新 llmdoc/reference/conversion-matrix.md - -## 降级策略参考 - -| 场景 | 推荐策略 | -|------|---------| -| 平台无 Agent 系统 | 降级为指令/规则文件 | -| 平台无 Hooks | 记录为文档 + 输出 warning | -| 平台 MCP 格式不同 | 做字段映射转换 | -| 平台无 Skills 概念 | 转为命令或规则文件 | -| Claude 特有字段 | 保留为 HTML 注释 | diff --git a/.agent/skills/npm-publish/SKILL.md b/.agent/skills/npm-publish/SKILL.md deleted file mode 100644 index ba940e5..0000000 --- a/.agent/skills/npm-publish/SKILL.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -name: npm-publish -description: 'Publish acplugin to npm with version bump, build, test, and 2FA handling' -disable-model-invocation: true ---- - -# npm 发布流程 - -## 步骤 - -1. **版本升级** - ```bash - npm version --no-git-tag-version - ``` - -2. **构建 + 测试** - ```bash - npm run build && npm test - ``` - -3. **检查打包内容**(确认无测试文件) - ```bash - npm pack --dry-run - ``` - -4. **发布** - 账号有 2FA,需要用户手动输入 OTP: - ``` - 提示用户运行: ! npm publish --access=public - ``` - -5. **Commit + Push** - ```bash - git add package.json package-lock.json - git commit -m "chore: bump version to $(node -p 'require("./package.json").version')" - git push - ``` - -## 注意事项 - -- 包名是 `@disdjj/acplugin`(scoped),必须加 `--access=public` -- 不要尝试在脚本中自动发布,2FA 会阻塞 -- `prepublishOnly` 脚本会自动编译 -- `files` 字段已排除 `dist/__tests__/` diff --git a/.agents/skills/add-converter/SKILL.md b/.agents/skills/add-converter/SKILL.md index fe30a9a..6049b85 100644 --- a/.agents/skills/add-converter/SKILL.md +++ b/.agents/skills/add-converter/SKILL.md @@ -1,69 +1,26 @@ --- name: add-converter -description: >- - Add a new resource type converter to acplugin (e.g., adding support for - converting a new Claude Code resource type) +description: Add or change a canonical acplugin Component and its Platform compilation, including schema, Resource discovery, compatibility, Package Assets, and tests. Use when adding a new authoring resource or changing how Commands, Skills, or Agents compile. --- -# 添加新资源类型转换器 +# Add a canonical Component -当需要支持转换新的 Claude Code 资源类型时,按以下步骤操作。 +1. Decide whether the feature belongs in Core. Only cross-platform authoring concepts may become Components; optional horizontal capabilities belong in Extensions. Do not add Instructions as a Component. +2. Add canonical and author contracts in `packages/core/src/contracts/components.ts`, with related configuration or public authoring surfaces kept in `packages/core/src/contracts/config.ts` and `packages/core/src/api/author.ts`. Keep Platform wire fields out of canonical types; use semantic fields and Platform-owned `platforms` metadata only where a verified capability requires it. +3. Update the focused providers under `packages/core/src/resources/canonical/` and the graph in `packages/core/src/resources/project-graph.ts` with strict path, Frontmatter, identity, dependency, and symlink validation. Providers return normalized data and diagnostics, never Platform files. +4. Update every built-in Platform owner under `packages/platforms//`. Each Platform decides its own native representation or explicit transformation and owns its Manifest, output paths, serialization, and candidate validation. +5. For every Platform, report `native`, `transform`, `degraded`, or `unsupported`. Strict mode must fail on degraded/unsupported; relaxed mode must emit the explicit result and warning. +6. Create only Core-signed AssetRef values, then map them into Platform base/final Package or add-only Extension Contribution. Platform/Extension code receives no physical output or workDir authority and never writes `dist` directly. +7. Add Core schema/graph tests under `packages/core/test/contracts/` and `packages/core/test/resources/`, per-Platform golden/schema tests under `packages/platforms//test/`, and cross-package strictness/collision tests in the matching domain under `packages/test/test/platforms/` or another existing integration-test domain. +8. Update the six-Platform compatibility tables, `AGENTS.md`, package README files, and the affected `llmdoc/` references. -## 步骤 +Run: -### 1. 定义类型 (`src/types.ts`) - -添加新资源的接口定义和 frontmatter 类型(如果有),以及在 `ScanResult` 中添加字段。在 `ConvertedFile.type` 联合类型中添加新值。 - -### 2. 添加扫描函数 (`src/scanner/claude.ts`) - -创建并导出可复用的扫描函数(如 `scanXxxDir()`),这样 `plugin.ts` 也能使用。 - -在 `scanClaudeProject()` 中调用新函数。 - -### 3. 集成 Plugin Scanner (`src/scanner/plugin.ts`) - -在 `scanPlugin()` 中调用新扫描函数,注意 plugin 目录结构与 .claude/ 不同: -- 项目: `.claude/xxx/` -- Plugin: `xxx/`(直接在 plugin 根目录下) - -更新 `countResources()` 包含新资源。 - -### 4. 创建 Converter (`src/converter/xxx.ts`) - -实现 `convertXxx(item, platform)` 函数,处理三个平台: - -```typescript -export function convertXxx(item: Xxx, platform: Platform): ConvertedFile { - switch (platform) { - case 'codex': return convertToCodex(item); - case 'opencode': return convertToOpenCode(item); - case 'cursor': return convertToCursor(item); - } -} +```bash +pnpm run lint +pnpm run typecheck +pnpm run test +pnpm run build ``` -**关键原则**: -- Converter 无副作用,只返回 `ConvertedFile` -- 不支持的功能用降级策略(合并到 AGENTS.md 或 rules) -- 返回 warnings 告知用户不兼容项 - -### 5. 集成 Writer (`src/writer/*.ts`) - -在三个 writer 文件中调用新 converter,处理合并逻辑。 - -### 6. 更新 CLI 输出 (`src/index.ts`) - -更新 `printScanResult()` 和 `convertSingleScan()` 中的资源计数。 - -### 7. 添加测试 (`src/__tests__/xxx.test.ts`) - -为新 converter 创建测试,覆盖三个平台的转换逻辑。 - -### 8. 更新 test-fixture/ - -在 `test-fixture/` 中添加新资源类型的示例文件,确保 `scanner.test.ts` 覆盖。 - -## Frontmatter 解析容错 - -社区插件的 YAML 可能格式不规范。扫描函数中必须 try-catch `parseFrontmatter()`,解析失败时用空 frontmatter + 原始内容兜底。 +Preserve deterministic path ordering, stable diagnostics, transactional all-Platform behavior, and independent public Platform package/peer boundaries. diff --git a/.agents/skills/add-platform/SKILL.md b/.agents/skills/add-platform/SKILL.md index 3abaee1..6e9cd13 100644 --- a/.agents/skills/add-platform/SKILL.md +++ b/.agents/skills/add-platform/SKILL.md @@ -1,75 +1,30 @@ --- name: add-platform -description: 'Add support for a new target platform to acplugin (e.g., Windsurf, Zed, etc.)' +description: Add a new acplugin Platform through an independent public package and official Extension Contributors. Use when introducing another AI platform or revising a Platform manifest, Component, Hooks, MCP, Package, Distribution, or compatibility contract. --- -# 添加新目标平台 - -当需要支持新的 AI 编程工具作为转换目标时,按以下步骤操作。 - -## 前置调研 - -1. 了解目标平台的配置格式: - - Skills/技能文件格式和路径 - - 自定义指令文件(类似 CLAUDE.md / AGENTS.md) - - MCP 服务器配置格式 - - Agent 定义方式(如果有) - - 命令/斜杠命令格式 - - Hooks 系统(如果有) - -2. 确认格式差异和降级策略 - -## 实施步骤 - -### 1. 类型注册 (`src/types.ts`) - -在 `Platform` 联合类型中添加新值: -```typescript -export type Platform = 'codex' | 'opencode' | 'cursor' | 'newplatform'; +# Add a Platform + +1. Verify the current target contract from primary documentation. Record whether delivery is a static Plugin, workspace overlay, or package; then record schema/path/install-root semantics, Component discovery, Hooks events/protocol, MCP transports/config, secret handling, and a real validation/install command. +2. Create one independent public `packages/platforms//` package. It imports only `@tokenroll/acplugin/sdk`, declares the main package as a `workspace:^` peer, implements `definePlatform()`, and owns an accurate `deliveryType`. +3. Keep all target-specific behavior in that package: + - validate Platform-specific Component fields; + - compile every canonical Component and report complete compatibility; + - own base Documents, extension points, Manifest fields, Package identity, and optional Distribution; + - create only Core-signed AssetRef values without direct output writes; + - validate identities, references, paths, tree closure, and the final materialized candidate. +4. Do not add a main-package re-export/subpath, Core Platform-ID branch, package registry, or official-only lifecycle path. The main package bundles private Core but never bundles an official Platform/Extension. +5. Add `PlatformContributor` implementations to Hooks/MCP only for verified capabilities. Every Contributor reads the same immutable base Package and returns an add-only Contribution; the Platform exposes controlled Document extension points and never imports an Extension. +6. Add the Platform to CLI selection, init metadata, ecosystem version snapshot, docs, and release verifier only after compatibility and empty-state behavior are defined. Do not silently expand the default Claude Code + Codex cohort. +7. Add package-owned golden/schema/candidate tests, strict/relaxed integration cases, real-consumer smoke appropriate to the delivery type, Hook runtime tests, MCP protocol tests, and owner/collision isolation. +8. Update the Platform matrices, `AGENTS.md`, package docs, TypeDoc entry set, tarball consumer verification, primary-source links, and contract verification date. + +Run the full repository and packed-consumer checks: + +```bash +pnpm run lint +pnpm run typecheck +pnpm run test +pnpm run build +pnpm run docs:check ``` - -### 2. 每个 Converter 添加分支 - -在所有 `src/converter/*.ts` 文件中,给 `switch (platform)` 添加新的 case。 - -参考现有平台的转换逻辑,特别关注: -- **路径映射**:新平台的目录结构 -- **Frontmatter 差异**:新平台是否需要特殊字段 -- **降级策略**:不支持的功能如何处理 - -### 3. 创建 Writer (`src/writer/newplatform.ts`) - -复制 `cursor.ts` 作为模板,修改平台名: -```typescript -export function generateNewPlatform(scan: ScanResult): ConvertResult { ... } -``` - -### 4. CLI 注册 (`src/index.ts`) - -- `generateForPlatform()` 添加新 case -- `validPlatforms` 数组添加新值 -- import 新 writer - -### 5. TUI 注册 (`src/tui.ts`) - -在 `selectPlatforms()` 的 choices 中添加新选项。 - -### 6. 测试 - -- 每个 converter 测试文件添加新平台的用例 -- 新增 `src/__tests__/newplatform-writer.test.ts`(可选) - -### 7. 文档 - -- 更新 README.md 和 README.zh-CN.md 的支持矩阵表格 -- 更新 llmdoc/reference/conversion-matrix.md - -## 降级策略参考 - -| 场景 | 推荐策略 | -|------|---------| -| 平台无 Agent 系统 | 降级为指令/规则文件 | -| 平台无 Hooks | 记录为文档 + 输出 warning | -| 平台 MCP 格式不同 | 做字段映射转换 | -| 平台无 Skills 概念 | 转为命令或规则文件 | -| Claude 特有字段 | 保留为 HTML 注释 | diff --git a/.agents/skills/npm-publish/SKILL.md b/.agents/skills/npm-publish/SKILL.md index ba940e5..dd623b6 100644 --- a/.agents/skills/npm-publish/SKILL.md +++ b/.agents/skills/npm-publish/SKILL.md @@ -1,44 +1,42 @@ --- name: npm-publish -description: 'Publish acplugin to npm with version bump, build, test, and 2FA handling' -disable-model-invocation: true +description: Prepare, version, or explicitly publish independently versioned ACPlugin public packages with Changesets and pnpm. Use for beta dry runs/local publication or manual stable Release Action dispatch. --- -# npm 发布流程 +# Release public packages -## 步骤 +Never create or push a tag, unpublish, change a dist-tag, or create a GitHub Release without explicit user authorization for that exact live mutation. Stable npm publication is permitted only through the repository's manually dispatched Release Action; beta npm publication is permitted only when the user explicitly authorizes the local command. -1. **版本升级** - ```bash - npm version --no-git-tag-version - ``` +## Prepare and verify -2. **构建 + 测试** - ```bash - npm run build && npm test - ``` +1. Confirm each affected public package has a Changeset. Versions remain independent. +2. After the feature reaches `main`, let `Changelog` create or update the version PR; do not manually consume the same Changesets concurrently. +3. Run behavior checks in proportion to risk: -3. **检查打包内容**(确认无测试文件) - ```bash - npm pack --dry-run - ``` +```bash +pnpm install --frozen-lockfile +pnpm run check +pnpm run docs:check +``` -4. **发布** - 账号有 2FA,需要用户手动输入 OTP: - ``` - 提示用户运行: ! npm publish --access=public - ``` +Official Platforms and Extensions must keep `@tokenroll/acplugin` as a `workspace:^` peer. pnpm rewrites that range when packing for publication. -5. **Commit + Push** - ```bash - git add package.json package-lock.json - git commit -m "chore: bump version to $(node -p 'require("./package.json").version')" - git push - ``` +## Publish manually -## 注意事项 +For beta versions, first inspect the no-write plan: -- 包名是 `@disdjj/acplugin`(scoped),必须加 `--access=public` -- 不要尝试在脚本中自动发布,2FA 会阻塞 -- `prepublishOnly` 脚本会自动编译 -- `files` 字段已排除 `dist/__tests__/` +```bash +pnpm run publish:beta:dry-run +``` + +Only after explicit authorization, publish from the merged version revision: + +```bash +pnpm run publish:beta -- --otp +``` + +For stable versions, exit Changesets prerelease mode, merge the stable version PR, then manually dispatch the `Release` Action from `main`. Do not add a push-triggered npm publication workflow. + +## Create release references manually + +Tags and GitHub Releases are separate, explicitly authorized maintenance actions. Never add an automated tag, dist-tag, or GitHub Release workflow. diff --git a/.agents/skills/npm-publish/agents/openai.yaml b/.agents/skills/npm-publish/agents/openai.yaml index 5430ef9..bbee3f1 100644 --- a/.agents/skills/npm-publish/agents/openai.yaml +++ b/.agents/skills/npm-publish/agents/openai.yaml @@ -1 +1,6 @@ -allow_implicit_invocation: false +interface: + display_name: "Publish acplugin" + short_description: "Verify and publish the fixed public package cohort" + default_prompt: "Use $npm-publish to prepare and verify an acplugin release without performing unapproved registry mutations." +policy: + allow_implicit_invocation: false diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..3e5ed4b --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.1.3/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": [ + "@acplugin/core", + "@acplugin/docs", + "@acplugin/playground", + "@acplugin/test" + ] +} diff --git a/.claude/skills/add-converter/SKILL.md b/.claude/skills/add-converter/SKILL.md deleted file mode 100644 index 0b6fff4..0000000 --- a/.claude/skills/add-converter/SKILL.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -name: add-converter -description: Add a new resource type converter to acplugin (e.g., adding support for converting a new Claude Code resource type) ---- - -# 添加新资源类型转换器 - -当需要支持转换新的 Claude Code 资源类型时,按以下步骤操作。 - -## 步骤 - -### 1. 定义类型 (`src/types.ts`) - -添加新资源的接口定义和 frontmatter 类型(如果有),以及在 `ScanResult` 中添加字段。在 `ConvertedFile.type` 联合类型中添加新值。 - -### 2. 添加扫描函数 (`src/scanner/claude.ts`) - -创建并导出可复用的扫描函数(如 `scanXxxDir()`),这样 `plugin.ts` 也能使用。 - -在 `scanClaudeProject()` 中调用新函数。 - -### 3. 集成 Plugin Scanner (`src/scanner/plugin.ts`) - -在 `scanPlugin()` 中调用新扫描函数,注意 plugin 目录结构与 .claude/ 不同: -- 项目: `.claude/xxx/` -- Plugin: `xxx/`(直接在 plugin 根目录下) - -更新 `countResources()` 包含新资源。 - -### 4. 创建 Converter (`src/converter/xxx.ts`) - -实现 `convertXxx(item, platform)` 函数,处理三个平台: - -```typescript -export function convertXxx(item: Xxx, platform: Platform): ConvertedFile { - switch (platform) { - case 'codex': return convertToCodex(item); - case 'opencode': return convertToOpenCode(item); - case 'cursor': return convertToCursor(item); - } -} -``` - -**关键原则**: -- Converter 无副作用,只返回 `ConvertedFile` -- 不支持的功能用降级策略(合并到 AGENTS.md 或 rules) -- 返回 warnings 告知用户不兼容项 - -### 5. 集成 Writer (`src/writer/*.ts`) - -在三个 writer 文件中调用新 converter,处理合并逻辑。 - -### 6. 更新 CLI 输出 (`src/index.ts`) - -更新 `printScanResult()` 和 `convertSingleScan()` 中的资源计数。 - -### 7. 添加测试 (`src/__tests__/xxx.test.ts`) - -为新 converter 创建测试,覆盖三个平台的转换逻辑。 - -### 8. 更新 test-fixture/ - -在 `test-fixture/` 中添加新资源类型的示例文件,确保 `scanner.test.ts` 覆盖。 - -## Frontmatter 解析容错 - -社区插件的 YAML 可能格式不规范。扫描函数中必须 try-catch `parseFrontmatter()`,解析失败时用空 frontmatter + 原始内容兜底。 diff --git a/.claude/skills/add-platform/SKILL.md b/.claude/skills/add-platform/SKILL.md deleted file mode 100644 index a09d3b8..0000000 --- a/.claude/skills/add-platform/SKILL.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -name: add-platform -description: Add support for a new target platform to acplugin (e.g., Windsurf, Zed, etc.) ---- - -# 添加新目标平台 - -当需要支持新的 AI 编程工具作为转换目标时,按以下步骤操作。 - -## 前置调研 - -1. 了解目标平台的配置格式: - - Skills/技能文件格式和路径 - - 自定义指令文件(类似 CLAUDE.md / AGENTS.md) - - MCP 服务器配置格式 - - Agent 定义方式(如果有) - - 命令/斜杠命令格式 - - Hooks 系统(如果有) - -2. 确认格式差异和降级策略 - -## 实施步骤 - -### 1. 类型注册 (`src/types.ts`) - -在 `Platform` 联合类型中添加新值: -```typescript -export type Platform = 'codex' | 'opencode' | 'cursor' | 'newplatform'; -``` - -### 2. 每个 Converter 添加分支 - -在所有 `src/converter/*.ts` 文件中,给 `switch (platform)` 添加新的 case。 - -参考现有平台的转换逻辑,特别关注: -- **路径映射**:新平台的目录结构 -- **Frontmatter 差异**:新平台是否需要特殊字段 -- **降级策略**:不支持的功能如何处理 - -### 3. 创建 Writer (`src/writer/newplatform.ts`) - -复制 `cursor.ts` 作为模板,修改平台名: -```typescript -export function generateNewPlatform(scan: ScanResult): ConvertResult { ... } -``` - -### 4. CLI 注册 (`src/index.ts`) - -- `generateForPlatform()` 添加新 case -- `validPlatforms` 数组添加新值 -- import 新 writer - -### 5. TUI 注册 (`src/tui.ts`) - -在 `selectPlatforms()` 的 choices 中添加新选项。 - -### 6. 测试 - -- 每个 converter 测试文件添加新平台的用例 -- 新增 `src/__tests__/newplatform-writer.test.ts`(可选) - -### 7. 文档 - -- 更新 README.md 和 README.zh-CN.md 的支持矩阵表格 -- 更新 llmdoc/reference/conversion-matrix.md - -## 降级策略参考 - -| 场景 | 推荐策略 | -|------|---------| -| 平台无 Agent 系统 | 降级为指令/规则文件 | -| 平台无 Hooks | 记录为文档 + 输出 warning | -| 平台 MCP 格式不同 | 做字段映射转换 | -| 平台无 Skills 概念 | 转为命令或规则文件 | -| Claude 特有字段 | 保留为 HTML 注释 | diff --git a/.claude/skills/npm-publish/SKILL.md b/.claude/skills/npm-publish/SKILL.md deleted file mode 100644 index ae7e464..0000000 --- a/.claude/skills/npm-publish/SKILL.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -name: npm-publish -description: Publish acplugin to npm with version bump, build, test, and 2FA handling -disable-model-invocation: true ---- - -# npm 发布流程 - -## 步骤 - -1. **版本升级** - ```bash - npm version --no-git-tag-version - ``` - -2. **构建 + 测试** - ```bash - npm run build && npm test - ``` - -3. **检查打包内容**(确认无测试文件) - ```bash - npm pack --dry-run - ``` - -4. **发布** - 账号有 2FA,需要用户手动输入 OTP: - ``` - 提示用户运行: ! npm publish --access=public - ``` - -5. **Commit + Push** - ```bash - git add package.json package-lock.json - git commit -m "chore: bump version to $(node -p 'require("./package.json").version')" - git push - ``` - -## 注意事项 - -- 包名是 `@disdjj/acplugin`(scoped),必须加 `--access=public` -- 不要尝试在脚本中自动发布,2FA 会阻塞 -- `prepublishOnly` 脚本会自动编译 -- `files` 字段已排除 `dist/__tests__/` diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json deleted file mode 100644 index 1fd7897..0000000 --- a/.cursor-plugin/plugin.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "converted-plugin", - "description": "Converted from Claude Code plugin via acplugin", - "version": "1.0.0", - "skills": "./skills/", - "rules": "./rules/" -} \ No newline at end of file diff --git a/.cursor/rules/claude-instructions.mdc b/.cursor/rules/claude-instructions.mdc deleted file mode 100644 index 10c0594..0000000 --- a/.cursor/rules/claude-instructions.mdc +++ /dev/null @@ -1,95 +0,0 @@ ---- -description: Project instructions imported from Claude Code CLAUDE.md -alwaysApply: true ---- -# acplugin 项目规范 - -## 项目概述 - -acplugin 是一个 CLI 工具,将 Claude Code 插件(Skills、Instructions、MCP、Agents、Commands、Hooks)转换为 Codex CLI、OpenCode 和 Cursor 格式。 - -## 技术栈 - -- TypeScript + Node.js (CommonJS) -- Commander.js (CLI) -- @inquirer/prompts + chalk (TUI) -- gray-matter (YAML frontmatter) -- @iarna/toml (TOML 序列化) -- vitest (测试) - -## 项目结构 - -``` -src/ -├── index.ts # CLI 入口 + 交互式 wizard -├── types.ts # 所有类型定义 -├── github.ts # GitHub 仓库下载 -├── tui.ts # TUI 交互(wizard、checkbox、彩色输出) -├── scanner/ -│ ├── claude.ts # .claude/ 项目结构扫描(导出可复用函数) -│ └── plugin.ts # .claude-plugin/ 插件格式扫描 -├── converter/ -│ ├── skill.ts # SKILL.md 转换 -│ ├── instructions.ts # CLAUDE.md → AGENTS.md / .mdc -│ ├── mcp.ts # .mcp.json → TOML / JSON -│ ├── agent.ts # Agent 定义转换(含降级策略) -│ ├── command.ts # Command 转换 -│ └── hooks.ts # Hooks 转换(含兼容性报告) -├── writer/ -│ ├── codex.ts # Codex 输出编排 -│ ├── opencode.ts # OpenCode 输出编排 -│ └── cursor.ts # Cursor 输出编排 -└── utils/ - ├── frontmatter.ts # YAML frontmatter 解析/序列化 - ├── toml.ts # TOML 工具 - └── fs.ts # 文件系统工具 -``` - -## 架构设计原则 - -- **三阶段 Pipeline**: Scanner → Converter → Writer -- **Scanner 提取可复用函数**: `scanSkillsDir()`, `scanAgentsDir()` 等被 claude.ts 和 plugin.ts 共用 -- **Converter 无副作用**: 接收数据,返回 `ConvertedFile`,不直接写文件 -- **Writer 负责编排**: 调用多个 converter,处理合并逻辑(如多个 instruction 合并为一个 AGENTS.md) -- **降级策略**: 目标平台不支持的功能降级为文档/规则,并输出 warning - -## 开发规范 - -### 添加新资源类型 -1. 在 `types.ts` 添加类型定义 -2. 在 `scanner/claude.ts` 添加扫描函数(导出为可复用) -3. 在 `scanner/plugin.ts` 集成 -4. 创建 `converter/xxx.ts`,实现三个平台的转换 -5. 在三个 `writer/*.ts` 中调用 converter -6. 添加测试 - -### 添加新目标平台 -1. 在 `types.ts` 的 `Platform` 联合类型添加新值 -2. 每个 `converter/*.ts` 添加新平台的转换逻辑 -3. 创建 `writer/newplatform.ts` -4. 在 `index.ts` 注册 -5. 在 `tui.ts` 的 `selectPlatforms()` 添加选项 -6. 添加测试 - -### Frontmatter 解析容错 -- 社区插件的 YAML frontmatter 可能格式不规范 -- `scanSkillsDir()` 和 `scanAgentsDir()` 已加 try-catch -- 解析失败时保留原始内容,frontmatter 设为空对象 - -### 测试 -- 测试文件在 `src/__tests__/` -- test-fixture/ 目录提供完整的 Claude Code 项目示例 -- 运行: `npm test` 或 `npx vitest run` -- 每个 converter 模块有独立测试文件 - -### npm 发布 -- 包名: `@disdjj/acplugin` -- 账号有 2FA,发布需要 OTP: `npm publish --access=public` -- `prepublishOnly` 自动编译 -- `files` 字段排除了 `dist/__tests__/` - -## Git 规范 - -- commit message 使用 conventional commits 格式 -- 仓库: https://github.com/TokenRollAI/acplugin -- 主分支: main diff --git a/.cursor/skills/add-converter/SKILL.md b/.cursor/skills/add-converter/SKILL.md deleted file mode 100644 index fe30a9a..0000000 --- a/.cursor/skills/add-converter/SKILL.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -name: add-converter -description: >- - Add a new resource type converter to acplugin (e.g., adding support for - converting a new Claude Code resource type) ---- - -# 添加新资源类型转换器 - -当需要支持转换新的 Claude Code 资源类型时,按以下步骤操作。 - -## 步骤 - -### 1. 定义类型 (`src/types.ts`) - -添加新资源的接口定义和 frontmatter 类型(如果有),以及在 `ScanResult` 中添加字段。在 `ConvertedFile.type` 联合类型中添加新值。 - -### 2. 添加扫描函数 (`src/scanner/claude.ts`) - -创建并导出可复用的扫描函数(如 `scanXxxDir()`),这样 `plugin.ts` 也能使用。 - -在 `scanClaudeProject()` 中调用新函数。 - -### 3. 集成 Plugin Scanner (`src/scanner/plugin.ts`) - -在 `scanPlugin()` 中调用新扫描函数,注意 plugin 目录结构与 .claude/ 不同: -- 项目: `.claude/xxx/` -- Plugin: `xxx/`(直接在 plugin 根目录下) - -更新 `countResources()` 包含新资源。 - -### 4. 创建 Converter (`src/converter/xxx.ts`) - -实现 `convertXxx(item, platform)` 函数,处理三个平台: - -```typescript -export function convertXxx(item: Xxx, platform: Platform): ConvertedFile { - switch (platform) { - case 'codex': return convertToCodex(item); - case 'opencode': return convertToOpenCode(item); - case 'cursor': return convertToCursor(item); - } -} -``` - -**关键原则**: -- Converter 无副作用,只返回 `ConvertedFile` -- 不支持的功能用降级策略(合并到 AGENTS.md 或 rules) -- 返回 warnings 告知用户不兼容项 - -### 5. 集成 Writer (`src/writer/*.ts`) - -在三个 writer 文件中调用新 converter,处理合并逻辑。 - -### 6. 更新 CLI 输出 (`src/index.ts`) - -更新 `printScanResult()` 和 `convertSingleScan()` 中的资源计数。 - -### 7. 添加测试 (`src/__tests__/xxx.test.ts`) - -为新 converter 创建测试,覆盖三个平台的转换逻辑。 - -### 8. 更新 test-fixture/ - -在 `test-fixture/` 中添加新资源类型的示例文件,确保 `scanner.test.ts` 覆盖。 - -## Frontmatter 解析容错 - -社区插件的 YAML 可能格式不规范。扫描函数中必须 try-catch `parseFrontmatter()`,解析失败时用空 frontmatter + 原始内容兜底。 diff --git a/.cursor/skills/add-platform/SKILL.md b/.cursor/skills/add-platform/SKILL.md deleted file mode 100644 index 3abaee1..0000000 --- a/.cursor/skills/add-platform/SKILL.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -name: add-platform -description: 'Add support for a new target platform to acplugin (e.g., Windsurf, Zed, etc.)' ---- - -# 添加新目标平台 - -当需要支持新的 AI 编程工具作为转换目标时,按以下步骤操作。 - -## 前置调研 - -1. 了解目标平台的配置格式: - - Skills/技能文件格式和路径 - - 自定义指令文件(类似 CLAUDE.md / AGENTS.md) - - MCP 服务器配置格式 - - Agent 定义方式(如果有) - - 命令/斜杠命令格式 - - Hooks 系统(如果有) - -2. 确认格式差异和降级策略 - -## 实施步骤 - -### 1. 类型注册 (`src/types.ts`) - -在 `Platform` 联合类型中添加新值: -```typescript -export type Platform = 'codex' | 'opencode' | 'cursor' | 'newplatform'; -``` - -### 2. 每个 Converter 添加分支 - -在所有 `src/converter/*.ts` 文件中,给 `switch (platform)` 添加新的 case。 - -参考现有平台的转换逻辑,特别关注: -- **路径映射**:新平台的目录结构 -- **Frontmatter 差异**:新平台是否需要特殊字段 -- **降级策略**:不支持的功能如何处理 - -### 3. 创建 Writer (`src/writer/newplatform.ts`) - -复制 `cursor.ts` 作为模板,修改平台名: -```typescript -export function generateNewPlatform(scan: ScanResult): ConvertResult { ... } -``` - -### 4. CLI 注册 (`src/index.ts`) - -- `generateForPlatform()` 添加新 case -- `validPlatforms` 数组添加新值 -- import 新 writer - -### 5. TUI 注册 (`src/tui.ts`) - -在 `selectPlatforms()` 的 choices 中添加新选项。 - -### 6. 测试 - -- 每个 converter 测试文件添加新平台的用例 -- 新增 `src/__tests__/newplatform-writer.test.ts`(可选) - -### 7. 文档 - -- 更新 README.md 和 README.zh-CN.md 的支持矩阵表格 -- 更新 llmdoc/reference/conversion-matrix.md - -## 降级策略参考 - -| 场景 | 推荐策略 | -|------|---------| -| 平台无 Agent 系统 | 降级为指令/规则文件 | -| 平台无 Hooks | 记录为文档 + 输出 warning | -| 平台 MCP 格式不同 | 做字段映射转换 | -| 平台无 Skills 概念 | 转为命令或规则文件 | -| Claude 特有字段 | 保留为 HTML 注释 | diff --git a/.cursor/skills/npm-publish/SKILL.md b/.cursor/skills/npm-publish/SKILL.md deleted file mode 100644 index ba940e5..0000000 --- a/.cursor/skills/npm-publish/SKILL.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -name: npm-publish -description: 'Publish acplugin to npm with version bump, build, test, and 2FA handling' -disable-model-invocation: true ---- - -# npm 发布流程 - -## 步骤 - -1. **版本升级** - ```bash - npm version --no-git-tag-version - ``` - -2. **构建 + 测试** - ```bash - npm run build && npm test - ``` - -3. **检查打包内容**(确认无测试文件) - ```bash - npm pack --dry-run - ``` - -4. **发布** - 账号有 2FA,需要用户手动输入 OTP: - ``` - 提示用户运行: ! npm publish --access=public - ``` - -5. **Commit + Push** - ```bash - git add package.json package-lock.json - git commit -m "chore: bump version to $(node -p 'require("./package.json").version')" - git push - ``` - -## 注意事项 - -- 包名是 `@disdjj/acplugin`(scoped),必须加 `--access=public` -- 不要尝试在脚本中自动发布,2FA 会阻塞 -- `prepublishOnly` 脚本会自动编译 -- `files` 字段已排除 `dist/__tests__/` diff --git a/.github/workflows/acplugin.yml b/.github/workflows/acplugin.yml deleted file mode 100644 index ab8ee90..0000000 --- a/.github/workflows/acplugin.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Convert Plugins -on: - push: - branches: [main] - paths: - - '.claude/**' - - 'CLAUDE.md' - -jobs: - convert: - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - - uses: TokenRollAI/acplugin-action@v1 - with: - platforms: codex,opencode,cursor,antigravity diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 0000000..abd78e6 --- /dev/null +++ b/.github/workflows/changelog.yml @@ -0,0 +1,35 @@ +name: Changelog + +on: + push: + branches: [main] + +concurrency: + group: changelog-main + cancel-in-progress: false + +permissions: + contents: write + pull-requests: write + +jobs: + version: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22.18.0 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Create or update the version PR + uses: changesets/action@v1 + with: + version: pnpm run version-packages + commit: "chore(release): version packages" + title: "chore(release): version packages" + env: + GITHUB_TOKEN: ${{ github.token }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..103f46a --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,25 @@ +name: Lint + +on: + pull_request: + types: [opened, synchronize, ready_for_review, reopened] + +concurrency: + group: lint-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22.18.0 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run lint diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml deleted file mode 100644 index bdb15ed..0000000 --- a/.github/workflows/publish-npm.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Publish to npm - -on: - push: - tags: - - "v*" - -permissions: - contents: read - id-token: write - -jobs: - publish: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - uses: actions/setup-node@v6 - with: - node-version: "24" - registry-url: "https://registry.npmjs.org" - - - name: Verify tag matches package version - run: | - package_version=$(node -p "require('./package.json').version") - if [ "v$package_version" != "$GITHUB_REF_NAME" ]; then - echo "Tag $GITHUB_REF_NAME does not match package.json version v$package_version" >&2 - exit 1 - fi - - - run: npm ci - - run: npm run build - - run: npm test - - run: npm pack --dry-run - - run: npm publish diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f95619e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,37 @@ +name: Release + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22.18.0 + cache: pnpm + registry-url: https://registry.npmjs.org/ + - run: pnpm install --frozen-lockfile + - name: Require stable public package versions + shell: bash + run: | + node --input-type=module <<'NODE' + import { readFile } from 'node:fs/promises' + import { publicPackageManifestPaths } from './scripts/public-packages.mjs' + + for (const file of publicPackageManifestPaths) { + const manifest = JSON.parse(await readFile(file, 'utf8')) + if (!/^\d+\.\d+\.\d+$/u.test(manifest.version)) + throw new Error(`Release only publishes stable versions; found ${manifest.name}@${manifest.version}.`) + } + NODE + - name: Publish stable public packages + run: pnpm run release + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml new file mode 100644 index 0000000..7127f1e --- /dev/null +++ b/.github/workflows/typecheck.yml @@ -0,0 +1,25 @@ +name: Typecheck + +on: + pull_request: + types: [opened, synchronize, ready_for_review, reopened] + +concurrency: + group: typecheck-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22.18.0 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run typecheck diff --git a/.gitignore b/.gitignore index f1bdeab..63820c0 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,7 @@ dist/ # llmdoc local temporary context cache (not project knowledge) .llmdoc-tmp/ + +# Reproducible documentation outputs +packages/docs/api/ +packages/docs/.vitepress/cache/ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..30ae3e8 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,2 @@ +pnpm exec lint-staged +pnpm run typecheck diff --git a/.opencode/skills/add-converter/SKILL.md b/.opencode/skills/add-converter/SKILL.md deleted file mode 100644 index fe30a9a..0000000 --- a/.opencode/skills/add-converter/SKILL.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -name: add-converter -description: >- - Add a new resource type converter to acplugin (e.g., adding support for - converting a new Claude Code resource type) ---- - -# 添加新资源类型转换器 - -当需要支持转换新的 Claude Code 资源类型时,按以下步骤操作。 - -## 步骤 - -### 1. 定义类型 (`src/types.ts`) - -添加新资源的接口定义和 frontmatter 类型(如果有),以及在 `ScanResult` 中添加字段。在 `ConvertedFile.type` 联合类型中添加新值。 - -### 2. 添加扫描函数 (`src/scanner/claude.ts`) - -创建并导出可复用的扫描函数(如 `scanXxxDir()`),这样 `plugin.ts` 也能使用。 - -在 `scanClaudeProject()` 中调用新函数。 - -### 3. 集成 Plugin Scanner (`src/scanner/plugin.ts`) - -在 `scanPlugin()` 中调用新扫描函数,注意 plugin 目录结构与 .claude/ 不同: -- 项目: `.claude/xxx/` -- Plugin: `xxx/`(直接在 plugin 根目录下) - -更新 `countResources()` 包含新资源。 - -### 4. 创建 Converter (`src/converter/xxx.ts`) - -实现 `convertXxx(item, platform)` 函数,处理三个平台: - -```typescript -export function convertXxx(item: Xxx, platform: Platform): ConvertedFile { - switch (platform) { - case 'codex': return convertToCodex(item); - case 'opencode': return convertToOpenCode(item); - case 'cursor': return convertToCursor(item); - } -} -``` - -**关键原则**: -- Converter 无副作用,只返回 `ConvertedFile` -- 不支持的功能用降级策略(合并到 AGENTS.md 或 rules) -- 返回 warnings 告知用户不兼容项 - -### 5. 集成 Writer (`src/writer/*.ts`) - -在三个 writer 文件中调用新 converter,处理合并逻辑。 - -### 6. 更新 CLI 输出 (`src/index.ts`) - -更新 `printScanResult()` 和 `convertSingleScan()` 中的资源计数。 - -### 7. 添加测试 (`src/__tests__/xxx.test.ts`) - -为新 converter 创建测试,覆盖三个平台的转换逻辑。 - -### 8. 更新 test-fixture/ - -在 `test-fixture/` 中添加新资源类型的示例文件,确保 `scanner.test.ts` 覆盖。 - -## Frontmatter 解析容错 - -社区插件的 YAML 可能格式不规范。扫描函数中必须 try-catch `parseFrontmatter()`,解析失败时用空 frontmatter + 原始内容兜底。 diff --git a/.opencode/skills/add-platform/SKILL.md b/.opencode/skills/add-platform/SKILL.md deleted file mode 100644 index 3abaee1..0000000 --- a/.opencode/skills/add-platform/SKILL.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -name: add-platform -description: 'Add support for a new target platform to acplugin (e.g., Windsurf, Zed, etc.)' ---- - -# 添加新目标平台 - -当需要支持新的 AI 编程工具作为转换目标时,按以下步骤操作。 - -## 前置调研 - -1. 了解目标平台的配置格式: - - Skills/技能文件格式和路径 - - 自定义指令文件(类似 CLAUDE.md / AGENTS.md) - - MCP 服务器配置格式 - - Agent 定义方式(如果有) - - 命令/斜杠命令格式 - - Hooks 系统(如果有) - -2. 确认格式差异和降级策略 - -## 实施步骤 - -### 1. 类型注册 (`src/types.ts`) - -在 `Platform` 联合类型中添加新值: -```typescript -export type Platform = 'codex' | 'opencode' | 'cursor' | 'newplatform'; -``` - -### 2. 每个 Converter 添加分支 - -在所有 `src/converter/*.ts` 文件中,给 `switch (platform)` 添加新的 case。 - -参考现有平台的转换逻辑,特别关注: -- **路径映射**:新平台的目录结构 -- **Frontmatter 差异**:新平台是否需要特殊字段 -- **降级策略**:不支持的功能如何处理 - -### 3. 创建 Writer (`src/writer/newplatform.ts`) - -复制 `cursor.ts` 作为模板,修改平台名: -```typescript -export function generateNewPlatform(scan: ScanResult): ConvertResult { ... } -``` - -### 4. CLI 注册 (`src/index.ts`) - -- `generateForPlatform()` 添加新 case -- `validPlatforms` 数组添加新值 -- import 新 writer - -### 5. TUI 注册 (`src/tui.ts`) - -在 `selectPlatforms()` 的 choices 中添加新选项。 - -### 6. 测试 - -- 每个 converter 测试文件添加新平台的用例 -- 新增 `src/__tests__/newplatform-writer.test.ts`(可选) - -### 7. 文档 - -- 更新 README.md 和 README.zh-CN.md 的支持矩阵表格 -- 更新 llmdoc/reference/conversion-matrix.md - -## 降级策略参考 - -| 场景 | 推荐策略 | -|------|---------| -| 平台无 Agent 系统 | 降级为指令/规则文件 | -| 平台无 Hooks | 记录为文档 + 输出 warning | -| 平台 MCP 格式不同 | 做字段映射转换 | -| 平台无 Skills 概念 | 转为命令或规则文件 | -| Claude 特有字段 | 保留为 HTML 注释 | diff --git a/.opencode/skills/npm-publish/SKILL.md b/.opencode/skills/npm-publish/SKILL.md deleted file mode 100644 index ba940e5..0000000 --- a/.opencode/skills/npm-publish/SKILL.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -name: npm-publish -description: 'Publish acplugin to npm with version bump, build, test, and 2FA handling' -disable-model-invocation: true ---- - -# npm 发布流程 - -## 步骤 - -1. **版本升级** - ```bash - npm version --no-git-tag-version - ``` - -2. **构建 + 测试** - ```bash - npm run build && npm test - ``` - -3. **检查打包内容**(确认无测试文件) - ```bash - npm pack --dry-run - ``` - -4. **发布** - 账号有 2FA,需要用户手动输入 OTP: - ``` - 提示用户运行: ! npm publish --access=public - ``` - -5. **Commit + Push** - ```bash - git add package.json package-lock.json - git commit -m "chore: bump version to $(node -p 'require("./package.json").version')" - git push - ``` - -## 注意事项 - -- 包名是 `@disdjj/acplugin`(scoped),必须加 `--access=public` -- 不要尝试在脚本中自动发布,2FA 会阻塞 -- `prepublishOnly` 脚本会自动编译 -- `files` 字段已排除 `dist/__tests__/` diff --git a/AGENTS.md b/AGENTS.md index f08a7b9..bfb246c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,91 +1,151 @@ -# acplugin 项目规范 - -## 项目概述 +# ACPlugin 项目规范 + +## 项目定位 + +ACPlugin 是基于 Rolldown 的统一 AI Plugin 框架和 CLI。作者通过规范化工程书写 Commands、Skills、Agents,以及可选 Hooks/MCP/Node Runtime;`init` 默认生成显式安装 Claude Code 与 Codex Platform 的工程,使用者也可安装 Cursor、Antigravity、OpenCode、Pi 或任意第三方 Platform。 + +- 公开包:`@tokenroll/acplugin`、六个 `@tokenroll/acplugin-platform-*`、`@tokenroll/acplugin-extension-hooks`、`@tokenroll/acplugin-extension-mcp` +- 私有包:Core、内部 Test、Docs、Playground workspace +- 不提供 Instructions Component +- 旧 Claude 工程/Plugin 的导入仅属于隔离的 Migration 子系统 + +## 技术与工具约束 + +- TypeScript 7、Node.js >=20、ESM-only;Package 的 `tsc` 来自 catalog 中的 `@typescript/native` +- pnpm workspace,不使用 npm/yarn,不引入 Turborepo +- tsdown 负责 package bundle 和声明文件 +- Vitest 只用于仓库内部测试 +- Commander.js + `@inquirer/prompts` 负责 CLI/TUI +- Core 基于 Rolldown 提供统一 Module/Build Service;Hooks、MCP、Node Runtime 与第三方集成都不得自建 Bundler +- VitePress 1.6 + TypeDoc 0.28 负责私有 Docs;TypeDoc 使用 TypeScript 6 Compiler API 兼容层 + +## Monorepo + +```text +packages/ +├── acplugin/ # 公开 CLI/facade,内含隔离 Migration +├── core/ # 私有配置、Resource、Rolldown、Runtime、生命周期、Asset/Package、事务 +├── platforms/ # 六个独立公开 Platform 实现包 +│ ├── claude-code/ +│ ├── codex/ +│ ├── cursor/ +│ ├── antigravity/ +│ ├── opencode/ +│ └── pi/ +├── extensions/ # 两个正式公开横向 Extension 包 +│ ├── hooks/ +│ └── mcp/ +├── test/ # 私有跨包 Vitest 集成测试 +├── docs/ # 私有 VitePress/TypeDoc 文档工程 +└── playground/ # 私有、领域中立的全能力消费模板 +``` -acplugin 是一个 CLI 工具,将 Claude Code 插件(Skills、Instructions、MCP、Agents、Commands、Hooks)转换为 Codex CLI、OpenCode 和 Cursor 格式。 +`@tokenroll/acplugin` 构建时必须 bundle Core,但不得 bundle 或重新导出官方 Platform/Extension。六个官方 Platform 与两个官方 Extension 都只能从主包公开 SDK 导入契约,并通过 `workspace:^` peer 开发边连接主包;pack 后必须变为正常 `^x.y.z`。任何公开 tarball 的运行时依赖都不得出现 `@acplugin/*`。 -## 技术栈 +## 统一构建架构 -- TypeScript + Node.js (CommonJS) -- Commander.js (CLI) -- @inquirer/prompts + chalk (TUI) -- gray-matter (YAML frontmatter) -- @iarna/toml (TOML 序列化) -- vitest (测试) +```text +Config → fixed Core lifecycle → Resource discovery → Canonical Project + → Platform base Package → unordered add-only Contributions + → finalized Package candidates → compatibility → transaction → BuildReport +``` -## 项目结构 +Core 固定生命周期为: +```text +config → setup Sessions → Resource/Extension discovery → Canonical Project +→ Component/Extension validation → Extension/Core Runtime compile +→ Platform.createPackage → Contributor collection → Core merge +→ Platform.finalizePackage → candidate materialize/validate +→ createDistributions/validate → compatibility propagation +→ managed transaction → reverse close ``` -src/ -├── index.ts # CLI 入口 + 交互式 wizard -├── types.ts # 所有类型定义 -├── github.ts # GitHub 仓库下载 -├── tui.ts # TUI 交互(wizard、checkbox、彩色输出) -├── scanner/ -│ ├── claude.ts # .claude/ 项目结构扫描(导出可复用函数) -│ └── plugin.ts # .claude-plugin/ 插件格式扫描 -├── converter/ -│ ├── skill.ts # SKILL.md 转换 -│ ├── instructions.ts # CLAUDE.md → AGENTS.md / .mdc -│ ├── mcp.ts # .mcp.json → TOML / JSON -│ ├── agent.ts # Agent 定义转换(含降级策略) -│ ├── command.ts # Command 转换 -│ └── hooks.ts # Hooks 转换(含兼容性报告) -├── writer/ -│ ├── codex.ts # Codex 输出编排 -│ ├── opencode.ts # OpenCode 输出编排 -│ └── cursor.ts # Cursor 输出编排 -└── utils/ - ├── frontmatter.ts # YAML frontmatter 解析/序列化 - ├── toml.ts # TOML 工具 - └── fs.ts # 文件系统工具 + +- Core 定义唯一阶段顺序、Context、诊断、兼容性、Asset 所有权和事务,不包含平台名称分支。 +- Platform 负责一种目标平台的 Component 转换、结构化 Document、base Package、主 Package 身份、可选 Marketplace Distribution 和最终候选校验。 +- Extension 负责横向作者能力;其 Built State 通过显式 `PlatformContributor` 对同一只读 base Package 返回无序 add-only `PackageContribution`。 +- Contributor 只能读取 base Package、向声明的 extension point 新增字段、追加自有 Asset、报告兼容性,或提交 subject-bound opaque Platform Component JSON;不能替换 Platform、完整 Document 或已有字段。 +- Core 只负责 Platform Component 的严格 JSON envelope、稳定合并和 provenance;目标 Platform 的 finalization 独占 payload schema、render、路径、Manifest 注册和不支持诊断。 +- Platform/Extension 不获得物理 workDir 或 `dist` 写权限;Source、Module、Compiler、Execution 和 Asset Service 由 Core 按 owner 授权。 +- Platform options 必须是深度冻结的 JSON;Extension 不提供依赖图和跨 Extension State 读取。 +- Session `close` 在成功/失败时均按初始化逆序执行,收到的异常只能是脱敏摘要。 +- CLI 与程序化 `runProject()` 必须只调用 Core 的唯一 Platform/Extension 生命周期,不得维护第二条构建路径。 + +## Canonical Components + +- Command:`src/commands/.md` +- Skill:`src/skills//SKILL.md`,同目录其他文件为辅助资源 +- Agent:`src/agents/.md` +- Public:默认 `public/`,支持 config copy 规则 + +所有 ID 使用小写 kebab-case。Markdown 必须有合法 YAML Frontmatter 和非空正文。依赖图必须拒绝缺失、自依赖、循环依赖。 + +兼容性必须显式:每个 Platform 都要逐资源报告 `native`、`transform`、`degraded` 或 `unsupported`。Claude 原生 Commands/Skills/Agents;Codex 原生 Skills、Commands 转 Skill、Agents 降级为 Skill;Cursor 原生三类 Component;Antigravity 以 Skill 转换 Command/Agent;OpenCode 生成 Workspace 原生资源;Pi 以 Prompt/Skill 转换 Command/Agent。严格模式不得静默接受 degraded/unsupported。 + +## Hooks/MCP Extension 与 Core Runtime + +- 未启用对应 Extension 时发现 `src/hooks` 或 `src/mcp` 内容必须失败;`src/runtime` 由 Core 直接拥有。 +- Hook 作者只返回语义结果,目标 stdin/stdout 协议由 Contributor 负责。 +- Hook runner 必须限制输入/输出、捕获顶层错误、使用固定脱敏错误码。 +- MCP 只支持 portable intersection:Streamable HTTP 与本地 stdio。 +- HTTP 的 secret 使用 `{ env }` 引用,构建过程不得读取值。 +- 本地 stdio MCP 必须是完整实现,并通过真实 `initialize`/`tools/list` smoke。 +- bundle 包含第三方包时,必须生成相邻 `THIRD_PARTY_LICENSES.txt`。 +- Node Runtime 默认把 `src/runtime/` 下受支持的一级 TS/JS 文件作为 executable 入口;嵌套文件只作为依赖。`runtime.entries` 完整替换自动发现,`runtime: false` 显式关闭。 +- Core 在 Scanner 后使用 `portable-node` 对每个 Runtime 入口只构建一次,owner 固定为 `framework:node-runtime`,并仅向声明稳定 Plugin-local Node 20 ESM capability 的平台交付。 +- Runtime 固定输出 `runtime//main.mjs` 与可选相邻许可证,不需要 descriptor、factory、Extension Contributor 或 Manifest patch。 +- 第三方 Platform/Extension 可使用 Core `managed-rolldown`,但 `cwd`、input、日志、输出目录、watch 与 close 始终由 Core 接管;许可证默认 `strict`,显式 `ignore` 表示调用方自行承担法律材料交付责任。 + +## Asset 与事务 + +- Asset 只允许 Core 签发的 Source、Generated 或 Bytes 引用,mode 只允许 `0644/0755`。 +- 输出路径拒绝绝对路径、NUL 和任何 `..` 片段,并拒绝符号链接、大小写及 Unicode 规范化冲突。 +- Platform 只能引用 Core 授权的 Component/Skill 辅助 Source 和自身 owner-scoped Service 生成的 Asset;Extension 只能引用自身发现或生成的 Asset;Public 只能引用 Core 精确发现的文件。 +- base/merged/primary Package 和 Distribution 必须保留继承 Asset 的 owner、mode、size 与 hash。 +- `dist` 是框架完整托管目录;成功构建按选中目标集合整体替换。 +- 事务顺序:锁 → 恢复 → stage → 校验 → transaction/backup → swap → cleanup。 +- 任一目标/阶段失败必须保留上次完整输出;事务修改必须补 fault-injection 测试。 +- 稳定报告和生成内容不得出现时间戳、绝对/临时路径、凭据或环境值。 + +## Migration 边界 + +Migration 位于 `packages/acplugin/src/migration/`,CLI 使用动态 import。`migration/legacy/` 只保留容错型 GitHub 下载与 Claude/plugin 扫描行为,为迁移读取服务;不得恢复旧 converter/writer/CLI/TUI。 + +- Core、Platform、Extension、正常 CLI 启动不得 import Migration。 +- Migration 不允许原地写入,也不把 Instructions/raw Hooks/外部命令 MCP 伪装为规范化资源。 +- 无法安全映射的内容进入 `.acplugin-migration/unmapped/` 和稳定 report。 +- 不要为了 Core 的严格类型规则大范围机械重写容错型 legacy 代码。 + +## 测试 + +- Core 单元测试:`packages/core/test/` +- 跨包集成:`packages/test/test/` +- Migration 集成:`packages/test/test/migration.test.ts` +- 根 workspace 与各正式 Package 统一使用 catalog 中的 `@typescript/native` 执行 TypeScript 7 编译和类型检查;依赖旧 Compiler API 的 Lint/注释工具及 TypeDoc 使用 `@typescript/typescript6` 兼容别名。 + +```bash +pnpm run lint +pnpm run typecheck +pnpm run test +pnpm run build +pnpm run docs:check ``` -## 架构设计原则 - -- **三阶段 Pipeline**: Scanner → Converter → Writer -- **Scanner 提取可复用函数**: `scanSkillsDir()`, `scanAgentsDir()` 等被 claude.ts 和 plugin.ts 共用 -- **Converter 无副作用**: 接收数据,返回 `ConvertedFile`,不直接写文件 -- **Writer 负责编排**: 调用多个 converter,处理合并逻辑(如多个 instruction 合并为一个 AGENTS.md) -- **降级策略**: 目标平台不支持的功能降级为文档/规则,并输出 warning - -## 开发规范 - -### 添加新资源类型 -1. 在 `types.ts` 添加类型定义 -2. 在 `scanner/claude.ts` 添加扫描函数(导出为可复用) -3. 在 `scanner/plugin.ts` 集成 -4. 创建 `converter/xxx.ts`,实现三个平台的转换 -5. 在三个 `writer/*.ts` 中调用 converter -6. 添加测试 - -### 添加新目标平台 -1. 在 `types.ts` 的 `Platform` 联合类型添加新值 -2. 每个 `converter/*.ts` 添加新平台的转换逻辑 -3. 创建 `writer/newplatform.ts` -4. 在 `index.ts` 注册 -5. 在 `tui.ts` 的 `selectPlatforms()` 添加选项 -6. 添加测试 - -### Frontmatter 解析容错 -- 社区插件的 YAML frontmatter 可能格式不规范 -- `scanSkillsDir()` 和 `scanAgentsDir()` 已加 try-catch -- 解析失败时保留原始内容,frontmatter 设为空对象 - -### 测试 -- 测试文件在 `src/__tests__/` -- test-fixture/ 目录提供完整的 Claude Code 项目示例 -- 运行: `npm test` 或 `npx vitest run` -- 每个 converter 模块有独立测试文件 - -### npm 发布 -- 包名: `@disdjj/acplugin` -- 账号有 2FA,发布需要 OTP: `npm publish --access=public` -- `prepublishOnly` 自动编译 -- `files` 字段排除了 `dist/__tests__/` - -## Git 规范 - -- commit message 使用 conventional commits 格式 -- 仓库: https://github.com/TokenRollAI/acplugin -- 主分支: main +新增功能必须按风险补充:schema/graph、Platform golden、Extension/Contributor 生命周期、Runtime 单次构建与能力交付、Asset owner 隔离、事务故障、CLI 子进程/退出码、Watch 恢复、Hook Contributor、MCP 协议、Migration 和 tarball consumer 测试。不得用缺少 fixture 的大面积 skip 代替验证。 + +## 发行 + +- 九个公开包由 Changesets 独立版本化;兼容性由 lifecycle `apiVersion` 和主包 peer range 表达,不使用 fixed group。 +- `Lint` 与 `Typecheck` Workflows 在 PR 创建、更新时独立执行;完整测试、Docs/Playground 和 tarball consumer 检查由开发者按改动风险运行。 +- `Changelog` Workflow 在 `main` 收到合并后检查未消费 Changeset,并只创建或更新版本与 CHANGELOG PR;它不发布任何 package。 +- `Release` Workflow 只能手工触发,只发布稳定 semver 版本到 npm `latest`;它不创建 Tag、GitHub Release 或独立 dist-tag 操作。 +- beta 版本由维护者在本地使用 `pnpm run publish:beta` 发布;先使用 `pnpm run publish:beta:dry-run` 检查结果。 +- 发布一律使用根命令的 `pnpm -r --filter '@tokenroll/*' publish --ignore-scripts`,让 pnpm 在已完成的根构建后打包公开 workspace、改写 `workspace:^` 并按依赖拓扑处理;不得恢复自定义 tarball 发布器或 Registry 轮询协议。 +- 禁止 unpublish、创建 Tag、GitHub Release 或修改已有 dist-tag,除非用户明确要求执行对应操作。 + +## Git 与改动安全 + +- commit message 使用 Conventional Commits。 +- 保留用户已有 staged/unstaged 修改,不使用 reset/checkout 覆盖。 +- 根目录旧版本产物和平台生成副本不应重新加入;仓库代理能力只维护 `.agents/skills/`。 +- `.llmdoc-tmp/` 是忽略的规划/调查缓存;稳定知识更新到 `llmdoc/`。 diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index f08a7b9..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,91 +0,0 @@ -# acplugin 项目规范 - -## 项目概述 - -acplugin 是一个 CLI 工具,将 Claude Code 插件(Skills、Instructions、MCP、Agents、Commands、Hooks)转换为 Codex CLI、OpenCode 和 Cursor 格式。 - -## 技术栈 - -- TypeScript + Node.js (CommonJS) -- Commander.js (CLI) -- @inquirer/prompts + chalk (TUI) -- gray-matter (YAML frontmatter) -- @iarna/toml (TOML 序列化) -- vitest (测试) - -## 项目结构 - -``` -src/ -├── index.ts # CLI 入口 + 交互式 wizard -├── types.ts # 所有类型定义 -├── github.ts # GitHub 仓库下载 -├── tui.ts # TUI 交互(wizard、checkbox、彩色输出) -├── scanner/ -│ ├── claude.ts # .claude/ 项目结构扫描(导出可复用函数) -│ └── plugin.ts # .claude-plugin/ 插件格式扫描 -├── converter/ -│ ├── skill.ts # SKILL.md 转换 -│ ├── instructions.ts # CLAUDE.md → AGENTS.md / .mdc -│ ├── mcp.ts # .mcp.json → TOML / JSON -│ ├── agent.ts # Agent 定义转换(含降级策略) -│ ├── command.ts # Command 转换 -│ └── hooks.ts # Hooks 转换(含兼容性报告) -├── writer/ -│ ├── codex.ts # Codex 输出编排 -│ ├── opencode.ts # OpenCode 输出编排 -│ └── cursor.ts # Cursor 输出编排 -└── utils/ - ├── frontmatter.ts # YAML frontmatter 解析/序列化 - ├── toml.ts # TOML 工具 - └── fs.ts # 文件系统工具 -``` - -## 架构设计原则 - -- **三阶段 Pipeline**: Scanner → Converter → Writer -- **Scanner 提取可复用函数**: `scanSkillsDir()`, `scanAgentsDir()` 等被 claude.ts 和 plugin.ts 共用 -- **Converter 无副作用**: 接收数据,返回 `ConvertedFile`,不直接写文件 -- **Writer 负责编排**: 调用多个 converter,处理合并逻辑(如多个 instruction 合并为一个 AGENTS.md) -- **降级策略**: 目标平台不支持的功能降级为文档/规则,并输出 warning - -## 开发规范 - -### 添加新资源类型 -1. 在 `types.ts` 添加类型定义 -2. 在 `scanner/claude.ts` 添加扫描函数(导出为可复用) -3. 在 `scanner/plugin.ts` 集成 -4. 创建 `converter/xxx.ts`,实现三个平台的转换 -5. 在三个 `writer/*.ts` 中调用 converter -6. 添加测试 - -### 添加新目标平台 -1. 在 `types.ts` 的 `Platform` 联合类型添加新值 -2. 每个 `converter/*.ts` 添加新平台的转换逻辑 -3. 创建 `writer/newplatform.ts` -4. 在 `index.ts` 注册 -5. 在 `tui.ts` 的 `selectPlatforms()` 添加选项 -6. 添加测试 - -### Frontmatter 解析容错 -- 社区插件的 YAML frontmatter 可能格式不规范 -- `scanSkillsDir()` 和 `scanAgentsDir()` 已加 try-catch -- 解析失败时保留原始内容,frontmatter 设为空对象 - -### 测试 -- 测试文件在 `src/__tests__/` -- test-fixture/ 目录提供完整的 Claude Code 项目示例 -- 运行: `npm test` 或 `npx vitest run` -- 每个 converter 模块有独立测试文件 - -### npm 发布 -- 包名: `@disdjj/acplugin` -- 账号有 2FA,发布需要 OTP: `npm publish --access=public` -- `prepublishOnly` 自动编译 -- `files` 字段排除了 `dist/__tests__/` - -## Git 规范 - -- commit message 使用 conventional commits 格式 -- 仓库: https://github.com/TokenRollAI/acplugin -- 主分支: main diff --git a/GEMINI.md b/GEMINI.md deleted file mode 100644 index f08a7b9..0000000 --- a/GEMINI.md +++ /dev/null @@ -1,91 +0,0 @@ -# acplugin 项目规范 - -## 项目概述 - -acplugin 是一个 CLI 工具,将 Claude Code 插件(Skills、Instructions、MCP、Agents、Commands、Hooks)转换为 Codex CLI、OpenCode 和 Cursor 格式。 - -## 技术栈 - -- TypeScript + Node.js (CommonJS) -- Commander.js (CLI) -- @inquirer/prompts + chalk (TUI) -- gray-matter (YAML frontmatter) -- @iarna/toml (TOML 序列化) -- vitest (测试) - -## 项目结构 - -``` -src/ -├── index.ts # CLI 入口 + 交互式 wizard -├── types.ts # 所有类型定义 -├── github.ts # GitHub 仓库下载 -├── tui.ts # TUI 交互(wizard、checkbox、彩色输出) -├── scanner/ -│ ├── claude.ts # .claude/ 项目结构扫描(导出可复用函数) -│ └── plugin.ts # .claude-plugin/ 插件格式扫描 -├── converter/ -│ ├── skill.ts # SKILL.md 转换 -│ ├── instructions.ts # CLAUDE.md → AGENTS.md / .mdc -│ ├── mcp.ts # .mcp.json → TOML / JSON -│ ├── agent.ts # Agent 定义转换(含降级策略) -│ ├── command.ts # Command 转换 -│ └── hooks.ts # Hooks 转换(含兼容性报告) -├── writer/ -│ ├── codex.ts # Codex 输出编排 -│ ├── opencode.ts # OpenCode 输出编排 -│ └── cursor.ts # Cursor 输出编排 -└── utils/ - ├── frontmatter.ts # YAML frontmatter 解析/序列化 - ├── toml.ts # TOML 工具 - └── fs.ts # 文件系统工具 -``` - -## 架构设计原则 - -- **三阶段 Pipeline**: Scanner → Converter → Writer -- **Scanner 提取可复用函数**: `scanSkillsDir()`, `scanAgentsDir()` 等被 claude.ts 和 plugin.ts 共用 -- **Converter 无副作用**: 接收数据,返回 `ConvertedFile`,不直接写文件 -- **Writer 负责编排**: 调用多个 converter,处理合并逻辑(如多个 instruction 合并为一个 AGENTS.md) -- **降级策略**: 目标平台不支持的功能降级为文档/规则,并输出 warning - -## 开发规范 - -### 添加新资源类型 -1. 在 `types.ts` 添加类型定义 -2. 在 `scanner/claude.ts` 添加扫描函数(导出为可复用) -3. 在 `scanner/plugin.ts` 集成 -4. 创建 `converter/xxx.ts`,实现三个平台的转换 -5. 在三个 `writer/*.ts` 中调用 converter -6. 添加测试 - -### 添加新目标平台 -1. 在 `types.ts` 的 `Platform` 联合类型添加新值 -2. 每个 `converter/*.ts` 添加新平台的转换逻辑 -3. 创建 `writer/newplatform.ts` -4. 在 `index.ts` 注册 -5. 在 `tui.ts` 的 `selectPlatforms()` 添加选项 -6. 添加测试 - -### Frontmatter 解析容错 -- 社区插件的 YAML frontmatter 可能格式不规范 -- `scanSkillsDir()` 和 `scanAgentsDir()` 已加 try-catch -- 解析失败时保留原始内容,frontmatter 设为空对象 - -### 测试 -- 测试文件在 `src/__tests__/` -- test-fixture/ 目录提供完整的 Claude Code 项目示例 -- 运行: `npm test` 或 `npx vitest run` -- 每个 converter 模块有独立测试文件 - -### npm 发布 -- 包名: `@disdjj/acplugin` -- 账号有 2FA,发布需要 OTP: `npm publish --access=public` -- `prepublishOnly` 自动编译 -- `files` 字段排除了 `dist/__tests__/` - -## Git 规范 - -- commit message 使用 conventional commits 格式 -- 仓库: https://github.com/TokenRollAI/acplugin -- 主分支: main diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9113de7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 TokenRollAI + +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/README.md b/README.md index 0beeac2..d914010 100644 --- a/README.md +++ b/README.md @@ -1,161 +1,432 @@ -# acplugin - -[![LINUX.DO](https://img.shields.io/badge/LINUX.DO-Community-f0b752?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0id2hpdGUiIGQ9Ik0xMiAyQzYuNDggMiAyIDYuNDggMiAxMnM0LjQ4IDEwIDEwIDEwIDEwLTQuNDggMTAtMTBTMTcuNTIgMiAxMiAyem0wIDE4Yy00LjQyIDAtOC0zLjU4LTgtOHMzLjU4LTggOC04IDggMy41OCA4IDgtMy41OCA4LTggOHoiLz48L3N2Zz4=)](https://linux.do) +# ACPlugin [中文文档](./README.zh-CN.md) -Convert [Claude Code](https://claude.ai/code) plugins to [Codex CLI](https://github.com/openai/codex), [OpenCode](https://opencode.ai/), [Cursor](https://cursor.com/), [Google Antigravity](https://antigravity.google/), and [Pi](https://github.com/earendil-works/pi) formats. +ACPlugin is a Rolldown-powered canonical AI plugin framework and CLI. You author Commands, Skills, Agents, and optional Hooks, MCP servers, or Node runtimes once; ACPlugin builds Platform-owned deliveries for Claude Code, Codex, Cursor, Antigravity, OpenCode, and Pi. + +This is not a Claude-project converter. The canonical project is the source of truth, and each Platform owns its final manifest, paths, compatibility decisions, and deterministic serialization. Legacy Claude projects and plugins are handled separately by `acplugin migrate`. + +## Requirements + +- Published CLI/runtime: Node.js `^20.19.0 || ^22.13.0 || >=23.5.0` +- Repository development/build: Node.js `^22.18.0 || >=24.11.0` +- pnpm for generated projects and this repository -## Install +## Quick start ```bash -npm install -g @disdjj/acplugin +pnpm dlx @tokenroll/acplugin init my-plugin --yes +cd my-plugin +pnpm install +pnpm build ``` -Or use directly with `npx`: +Or add the framework and the Platforms you want to an existing empty project: ```bash -npx @disdjj/acplugin convert . +pnpm add -D @tokenroll/acplugin \ + @tokenroll/acplugin-platform-claude-code \ + @tokenroll/acplugin-platform-codex ``` -## Quick Start +```ts +// acplugin.config.ts +import { defineConfig } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [claudeCode(), codex()], +}); +``` -```bash -# Interactive wizard — just run acplugin! -acplugin +`init` selects Claude Code and Codex unless you pass `--platform`, but it writes both packages and imports explicitly. The runtime has no implicit Platforms: every build uses exactly the instances in `platforms`. + +`acplugin.config.ts`, Hook descriptors, and MCP descriptors are trusted executable project code loaded by the local Node.js process. Review them with the same care as build scripts; Migration input remains untrusted data and is never executed as canonical descriptor code. + +## Canonical project + +```text +my-plugin/ +├── acplugin.config.ts +├── package.json +├── public/ # optional files copied to each Platform root +└── src/ + ├── commands/ + │ └── review.md + ├── skills/ + │ └── review/ + │ ├── SKILL.md + │ └── references/ # copied with the Skill + ├── agents/ + │ └── reviewer.md + ├── hooks/ # only with the Hooks Extension + │ └── policy/hook.ts + ├── mcp/ # only with the MCP Extension + │ └── docs/mcp.ts + └── runtime/ # optional Core-managed Node Runtime sources + ├── cli.ts # direct files are entries by convention + └── internal/helpers.ts # nested files are normal dependencies +``` + +IDs and directory names use lowercase kebab-case. Markdown Components require YAML Frontmatter and a non-empty body. Symlinks and paths escaping the project are rejected. + +ACPlugin deliberately has no Instructions Component. Repository-wide instructions are host/project configuration, not an installable plugin capability. + +## Configuration + +`acplugin.config.ts` exports an object or a sync/async function receiving `{ command, mode }`. + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; + +export default defineConfig(({ mode }) => ({ + name: 'team-review', + version: '1.0.0', + description: 'Shared review workflows.', + displayName: 'Team Review', + platforms: [ + claudeCode(), + codex({ strict: mode === 'production' }), + ], + public: { + dir: 'public', + copy: [ + { from: 'assets', to: 'assets' }, + { from: 'NOTICE.md', to: 'NOTICE.md' }, + ], + }, + build: { + outDir: 'dist', + strict: true, + }, +})); +``` + +Top-level fields: + +| Field | Meaning | +| --- | --- | +| `name`, `version`, `description` | Required plugin identity. | +| `displayName` | Optional presentation name. | +| `srcDir` | Canonical source directory; defaults to `src`. | +| `public` | `false`, a directory, or explicit copy rules. | +| `platforms` | Required, non-empty list of explicitly imported Platform instances. | +| `runtime` | Built-in Node Runtime convention, explicit entries, compile options, or `false`. | +| `extensions` | Optional horizontal capabilities such as Hooks and MCP. | +| `build.outDir` | Managed output directory; defaults to `dist`. | +| `build.strict` | Fail on degraded/unsupported compatibility; defaults to `true`. | + +Official Platforms are independent packages with a peer dependency on the framework: + +```ts +import antigravity from '@tokenroll/acplugin-platform-antigravity'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; +import cursor from '@tokenroll/acplugin-platform-cursor'; +import openCode from '@tokenroll/acplugin-platform-opencode'; +import pi from '@tokenroll/acplugin-platform-pi'; + +const platforms = [claudeCode(), codex(), cursor(), antigravity(), openCode(), pi()]; +``` + +Claude Code, Codex, Cursor, and Antigravity emit static Plugin Packages. OpenCode emits a workspace overlay; Pi emits an npm package. `acplugin init --platform ` installs and writes the selected packages explicitly. The main package does not re-export official integrations, discover packages by ID, or install anything during a build. -# Convert current project -acplugin convert . +## Core Components -# Convert from GitHub -acplugin convert anthropics/claude-code --all --to cursor +### Skill -# Scan resources without converting -acplugin scan anthropics/claude-code +```md +--- +description: Review a change for correctness and maintainability. +invocation: + user: true + model: true +requires: + agents: [reviewer] +--- +Review the selected change and report concrete findings. ``` -## Features +Place it at `src/skills/review/SKILL.md`. Every other regular file below that directory is copied as a Skill auxiliary file. -- Converts Skills, Instructions, MCP configs, Agents, Commands, and Hooks -- **5 target platforms**: Codex CLI, OpenCode, Cursor, Google Antigravity, Pi -- Full subagent conversion with proper format for each platform -- Automatic model mapping (Claude → GPT-5.4 / Gemini 3 Pro) -- Supports Claude Code Plugin marketplace format (multi-plugin repos) -- Interactive TUI with checkbox selection for plugins and platforms -- Direct GitHub repo support — no need to clone first -- Smart detection: auto-detects local projects, plugins, and marketplace repos +### Command -## Supported Conversions +```md +--- +description: Review a named change. +argumentHint: +requires: + skills: [review] +--- +Review {{arguments}} using the review Skill. +``` + +Place it at `src/commands/review.md`. -| Resource | Codex CLI | OpenCode | Cursor | Antigravity | Pi | -| ---------------- | ------------------------- | ------------------------- | --------------------- | ------------------------- | ------------------ | -| **Skills** | `.agents/skills/` | `.opencode/skills/` | `.cursor/skills/` | `.agents/skills/` | `.pi/skills/` | -| **Instructions** | `AGENTS.md` | `AGENTS.md` | `.cursor/rules/*.mdc` | `GEMINI.md` | `AGENTS.md` | -| **MCP Servers** | `.codex/config.toml` | `opencode.json` | `.cursor/mcp.json` | `.agents/mcp_config.json` | Unsupported (warn) | -| **Agents** | `.codex/agents/*.toml` | `.opencode/agents/*.md` | `.cursor/agents/*.md` | `.agents/agents/*.md` | Unsupported (warn) | -| **Commands** | Converted to Skills | `.opencode/commands/` | `.cursor/commands/` | Converted to Skills | `.pi/prompts/*.md` | -| **Hooks** | Documented in `AGENTS.md` | Documented in `AGENTS.md` | Warnings only | Warnings only | Warnings only | +### Agent + +```md +--- +description: Focused read-only code reviewer. +model: capable +capabilities: [filesystem:read, search] +--- +Inspect the change, verify evidence, and report only actionable findings. +``` -[Pi](https://github.com/earendil-works/pi) (pi-coding-agent) is a minimal terminal harness whose only native file formats are Claude-style Skills and instruction files. Commands degrade to prompt templates; MCP/Agents/Hooks have no target format (Pi extends via TypeScript extensions) and emit warnings. +Place it at `src/agents/reviewer.md`. Canonical model classes are `inherit`, `fast`, and `capable`. Capabilities are semantic declarations rather than target tool names. -### Model Mapping +Components may require Skills and Agents. Missing dependencies, self-dependencies, and cycles are build errors. -| Claude Code | → Codex | → Antigravity | -| ----------------- | -------------- | ----------------------- | -| `sonnet` / `opus` | `gpt-5.6-sol` | `gemini-3.1-pro-preview` | -| `haiku` | `gpt-5.6-terra`| `gemini-3.6-flash` | -| (not specified) | `gpt-5.6-sol` | `gemini-3.1-pro-preview` | +## Compatibility -OpenCode, Cursor, and Pi keep the original model value. +| Component | Claude Code | Codex | Cursor | Antigravity | OpenCode | Pi | +| --- | --- | --- | --- | --- | --- | --- | +| Skill | Native | Native | Native | Native | Native | Native | +| Command | Native | Transform to Skill | Native | Transform to Skill | Native | Transform to Prompt | +| Agent | Native | Degraded Skill | Native with field-level limits | Degraded Skill | Native with capability transform | Degraded Skill | -## CLI Reference +Codex installable plugins cannot register custom project/user Agents. Therefore an Agent makes a strict Codex build fail; configure `codex({ strict: false })` only when the explicit fallback and its structured warning are acceptable. -### `acplugin scan [source]` +See the [complete compatibility matrix](./llmdoc/reference/conversion-matrix.md) for Package shapes, every portable Hook event, and MCP transport support. -Scan and list convertible resources. +## Hooks Extension ```bash -acplugin scan . # Current directory -acplugin scan ./my-project # Local path -acplugin scan anthropics/claude-code # GitHub repo -acplugin scan https://github.com/owner/repo # Full GitHub URL -acplugin scan owner/repo --path plugins/foo # Sub-path in repo +pnpm add -D @tokenroll/acplugin-extension-hooks ``` -### `acplugin convert [source]` +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import hooks from '@tokenroll/acplugin-extension-hooks'; + +export default defineConfig({ + name: 'policy-plugin', + version: '1.0.0', + description: 'Portable policy hooks.', + platforms: [claudeCode()], + extensions: [hooks()], +}); +``` + +```ts +// src/hooks/policy/hook.ts +import type { Hook } from '@tokenroll/acplugin-extension-hooks'; + +export default { + event: 'PreToolUse', + matcher: 'Bash', + timeout: 5, + async run(input) { + return input.cwd + ? { decision: 'allow' } + : { decision: 'deny', reason: 'Missing working directory.' }; + }, +} satisfies Hook<'PreToolUse'>; +``` -Convert Claude Code plugins to target platform formats. +Portable events are: -```bash -acplugin convert . # Interactive: select platforms -acplugin convert . --to cursor # Specify platform -acplugin convert . --to codex,antigravity # Multiple platforms -acplugin convert anthropics/claude-code # From GitHub, interactive -acplugin convert anthropics/claude-code --all # All plugins, no prompt -acplugin convert . -o ./output # Custom output directory -acplugin convert . --dry-run # Preview without writing +```text +SessionStart, SessionEnd, UserPromptSubmit, PreToolUse, PermissionRequest, +PostToolUse, PreCompact, PostCompact, SubagentStart, SubagentStop, Stop ``` -**Options:** +Claude Code-only events remain explicitly platform-scoped and do not affect Codex compatibility: -| Option | Description | -| ---------------------- | -------------------------------------------------------------------------------- | -| `-t, --to ` | Target platforms (comma-separated: `codex`, `opencode`, `cursor`, `antigravity`, `pi`) | -| `-o, --output ` | Output directory | -| `-a, --all` | Convert all plugins without interactive selection | -| `-p, --path ` | Sub-path within repository | -| `--dry-run` | Show what would be generated without writing | +```text +Setup, UserPromptExpansion, PermissionDenied, PostToolUseFailure, PostToolBatch, +Notification, MessageDisplay, TaskCreated, TaskCompleted, StopFailure, +TeammateIdle, InstructionsLoaded, ConfigChange, CwdChanged, DirectoryAdded, +FileChanged, WorktreeCreate, WorktreeRemove, Elicitation, ElicitationResult +``` + +Declare one with `event: { platform: 'claude-code', name: 'Setup' }`; a bare `'Setup'` string is rejected. -## Examples +ACPlugin bundles each handler once as a self-contained, platform-neutral Node 20 ESM executable. Verified platform wire profiles are compiled into that same Bundle for native input validation, recursive camelCase conversion, root/data mapping, and output mapping; no adjacent runtime JavaScript is required. The shared Handler owns bounded JSON I/O, semantic result validation, safe failures, and deterministic third-party license notices. Meaningful matchers ignored by the selected host are reported per Hook as `degraded`; unsupported events generate no fake runtime. -### Convert a local project +## MCP Extension ```bash -cd my-project -acplugin convert . --to cursor,antigravity +pnpm add -D @tokenroll/acplugin-extension-mcp ``` -### Convert from GitHub Plugin Marketplace +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import mcp from '@tokenroll/acplugin-extension-mcp'; + +export default defineConfig({ + name: 'tools-plugin', + version: '1.0.0', + description: 'Portable MCP tools.', + platforms: [claudeCode()], + extensions: [mcp()], +}); +``` -```bash -# Interactive: browse and select plugins -acplugin convert anthropics/claude-code +Remote Streamable HTTP server: + +```ts +// src/mcp/docs/mcp.ts +import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; + +export default { + transport: 'http', + url: 'https://example.com/mcp', + auth: { type: 'bearer', env: 'DOCS_TOKEN' }, + headers: { 'X-Tenant': { env: 'TENANT_ID' } }, +} satisfies McpServer; +``` + +Local stdio server: + +```ts +// src/mcp/local-tools/mcp.ts +import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; + +export default { + transport: 'stdio', + entry: 'server.ts', + env: { API_TOKEN: { env: 'LOCAL_API_TOKEN' } }, +} satisfies McpServer; +``` -# Convert all plugins to all platforms -acplugin convert anthropics/claude-code --all -o ./converted +For local MCP, you provide a complete stdio MCP implementation in `server.ts`; ACPlugin bundles it for Node 20 ESM. Both development and production builds reject unresolved runtime dynamic imports, start the bundle with only declared literal environment values, and require a bounded `initialize → initialized → tools/list` smoke test to pass. No mode branch or cache bypasses this protocol check. Referenced secret values are never read. For HTTP MCP, you declare the remote endpoint and auth/header references—there is no local server implementation to provide. Production HTTP endpoints require HTTPS; development permits loopback HTTP. + +Claude Code, Codex, and OpenCode support both remote HTTP and bundled local stdio. Cursor and Antigravity support remote HTTP only; Pi reports MCP unsupported. See the [complete compatibility matrix](./llmdoc/reference/conversion-matrix.md). + +## Built-in Node Runtime + +```ts +// acplugin.config.ts +export default defineConfig({ + // ...metadata and explicit Platforms + runtime: { + entries: { + cli: { entry: 'bin/cli.ts', kind: 'executable' }, + library: { entry: 'library.ts', kind: 'module' }, + }, + compile: { treeshake: true }, + }, +}); +``` + +With no `runtime` field, every supported direct file under `src/runtime/` is an executable entry; nested files remain normal dependencies. An explicit `runtime.entries` map completely replaces auto-discovery, and `runtime: false` disables the convention. Each entry becomes one deterministic, self-contained Node 20 ESM bundle at `runtime//main.mjs`. npm dependencies are bundled, only `node:` built-ins remain external, executable entries use mode `0755`, module entries use `0644`, and third-party notices are emitted next to the bundle when required. Core compiles every entry once, then Claude Code and Codex inherit the same framework-owned bytes. Platforms without a stable local Node/plugin-root contract report `unsupported` and receive no substitute Asset. Type checking remains the project-owned `tsc --noEmit` step. + +## Extension lifecycle + +All Extensions participate in the same Core-owned pipeline: + +```text +config → setup Sessions → discover Resources → Canonical Project +→ validate → compile → Platform base Package → Contributors → Core merge +→ finalize → materialize/validate candidates → Distributions +→ compatibility → transaction → reverse close ``` -### Scan a repo to see available resources +Descriptor loading goes through `context.modules`, while executable output goes through the Core-owned Rolldown service at `context.compiler`. The services register the actual module, license, plugin, and tsconfig graph for `dev`; integrations receive owner-scoped capabilities, do not create private bundlers, and cannot write `dist`. Platform Contributors can return owned Assets, add fields at declared Document extension points, and report compatibility from the same immutable base Package. They can also submit an opaque Platform Component Contribution using the target Platform package's public payload type: Core transports only JSON and provenance, while that Platform validates, renders, names, and registers its native resource during finalization. Unsupported Platforms fail a non-empty contribution instead of silently dropping it or generating a fallback. Contributors cannot replace Platform output or observe other Extension state. Session `close` always runs in reverse initialization order. + +## CLI + +```text +acplugin init [directory] +acplugin dev +acplugin validate +acplugin inspect +acplugin build +acplugin migrate [destination] +``` + +Common project options include `--config`, `--platform`, `--mode`, and `--json`. Compatibility strictness is declared in `acplugin.config.ts` through `build.strict` or a Platform factory override. + +- `validate` runs complete Platform generation and materialization validation without writing `dist`. +- `inspect` adds detailed Package/Asset metadata without writing `dist`. +- `build` atomically replaces the complete managed `dist` only after every selected Platform succeeds. +- `dev` watches config, the Core Module/Build Service graph, Components, Public files, descriptors, and bundler/plugin/license/tsconfig dependencies. Package dependencies are watched at their resolved package roots. It performs a catch-up build after each new watcher becomes ready, retains the last successful output after failures, and rebuilds after recovery. Runtime-computed import targets that Rolldown cannot place in a static module graph are rejected for managed executable bundles. +- Bare `acplugin` prints Help and never prompts. + +Exit codes are `0` success, `1` project/build/migration failure, `2` CLI usage or internal framework failure, and `130` cancellation. JSON mode writes one schema-versioned document to stdout for non-watch commands; diagnostics/logs use stderr. + +## Deterministic output and security + +- Assets are immutable owner-scoped references reported with mode, size, SHA-256, and structured origin. +- Absolute/traversal paths, symlinks, path collisions, and sources outside approved roots are rejected. +- Builds use a same-filesystem stage, lock, transaction record, backup, and whole-output swap. +- Any Platform failure preserves the previous complete `dist`. +- Generated files and reports contain no timestamps, temporary paths, environment values, or credentials. +- Extension source under `src/hooks` or `src/mcp` without its Extension enabled is an error; `src/runtime` is owned directly by Core. + +## Legacy Migration + +Migration is CLI-only, lazy-loaded, and isolated from Core/Platforms/normal startup. ```bash -$ acplugin scan anthropics/claude-code +acplugin migrate ./legacy-project ./new-plugin \ + --name new-plugin \ + --description "Migrated plugin" + +acplugin migrate owner/repository ./new-workspace --all +``` + +Supported sources include local Claude projects, single plugins, marketplaces, and supported GitHub forms. `--plugin ` writes one canonical project directly at the destination; only `--all` creates a pnpm workspace of independent projects. Skills, Commands, Agents, and portable remote HTTP MCP declarations are mapped where possible. Instructions, raw Hooks, Hook implementation files, local external-command MCP, and unsupported resources are preserved under `.acplugin-migration/unmapped/` with a stable report and manual actions. Before atomic commit, every generated project is loaded through the public API and checked by the real Core Module Service, Scanner, lifecycle, and isolated Migration validators; installed Platform/Extension packages perform their full semantic validation when the generated project is built. Migration never writes in place. + +Use `--dry-run` for scan/map/validation without destination writes and `--strict` to fail on any degraded or unmapped item. + +## Documentation and playground + +The repository includes two private, repository-only workspaces beside the publishable packages: -Claude Code Plugin Marketplace -✔ Found 13 plugin(s) with resources +- `packages/docs` is a VitePress site with task-oriented Guide, Config, Platform, Extension, Ecosystem, Playground, and Resource sections. TypeDoc regenerates API pages and the sidebar for all nine public package root entries before every docs dev/build. +- `packages/playground` is a domain-neutral six-Platform/Hooks/MCP/Node Runtime capability template. It validates canonical Commands, Skill auxiliary files, Agents, all portable Hook events, HTTP and local MCP, portable Node runtime delivery, Public files, and Claude Code/Codex Marketplaces without implementing product-specific behavior. -1. agent-sdk-dev [development] — 3 resource(s) -2. code-review [productivity] — 1 resource(s) -3. commit-commands [productivity] — 3 resource(s) -... +```bash +pnpm run docs:dev # generate API pages, then start VitePress +pnpm run docs:build # generate API pages and build the static site +pnpm run docs:check # docs structure/build plus the real playground checks ``` -### Private repos +Generated API Markdown/sidebar, VitePress cache/output, and Playground `dist` are reproducible and ignored by Git. + +## Packages and repository development + +Public packages: + +- `@tokenroll/acplugin` +- `@tokenroll/acplugin-platform-claude-code` +- `@tokenroll/acplugin-platform-codex` +- `@tokenroll/acplugin-platform-cursor` +- `@tokenroll/acplugin-platform-antigravity` +- `@tokenroll/acplugin-platform-opencode` +- `@tokenroll/acplugin-platform-pi` +- `@tokenroll/acplugin-extension-hooks` +- `@tokenroll/acplugin-extension-mcp` -Set `GITHUB_TOKEN` to access private repositories: +The official integrations use the same public lifecycle SDK available to third-party packages and declare the main package as a peer dependency. Core, the Vitest integration workspace, Docs, and Playground remain private; Core is bundled into the main package and no public runtime manifest contains `@acplugin/*`. ```bash -export GITHUB_TOKEN=ghp_xxx -acplugin convert my-org/private-plugins --all --to codex +pnpm install +pnpm run check +pnpm run docs:check ``` -## How It Works +Pull requests run separate Lint and Typecheck Actions. After a feature PR containing Changesets merges into `main`, the Changelog Action consumes the pending Changesets and opens or updates a version PR containing the independent package version bumps and changelogs. It never publishes packages. -1. **Scan** — Detects Claude Code resources: `.claude/` project structure, `.claude-plugin/` plugin format, or marketplace repos -2. **Select** — Interactive TUI lets you pick which plugins and platforms to target -3. **Convert** — Transforms each resource to the target platform's format, with model mapping and field adaptation -4. **Report** — Shows what was generated, with warnings for resources that couldn't be fully converted +The manually dispatched Release Action only publishes stable semver versions to npm `latest`; it does not create tags or GitHub Releases. Beta publication stays local to an authorized maintainer: + +```bash +pnpm run publish:beta:dry-run +pnpm run publish:beta +``` -Claude-specific features (like `context: fork`, `agent: Explore`) are preserved as HTML comments in the output files for reference. +Both beta and stable release commands build once and then use pnpm's recursive public-workspace publish flow, which rewrites `workspace:^` peer ranges in packed manifests. Packages are independently versioned; do not create tags or GitHub Releases unless separately authorized. ## License diff --git a/README.zh-CN.md b/README.zh-CN.md index 32d3d56..48ba95d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,158 +1,429 @@ -# acplugin +# ACPlugin -将 [Claude Code](https://claude.ai/code) 插件转换为 [Codex CLI](https://github.com/openai/codex)、[OpenCode](https://opencode.ai/)、[Cursor](https://cursor.com/)、[Google Antigravity](https://antigravity.google/) 和 [Pi](https://github.com/earendil-works/pi) 格式。 +[English](./README.md) -## 安装 +ACPlugin 是一个基于 Rolldown 的统一 AI Plugin 框架和 CLI。开发者只维护一套 Commands、Skills、Agents,以及可选的 Hooks、MCP、Node Runtime 源码,ACPlugin 将其构建为 Claude Code、Codex、Cursor、Antigravity、OpenCode 和 Pi 各自拥有的交付产物。 + +它不再以 Claude 工程为默认输入进行“格式转换”。规范化工程才是唯一事实来源,每个 Platform 负责最终 Manifest、路径、兼容性判断和确定性序列化。旧 Claude 工程/Plugin 的导入由隔离的 `acplugin migrate` 负责。 + +## 环境要求 + +- 已发布 CLI/运行时:Node.js `^20.19.0 || ^22.13.0 || >=23.5.0` +- 仓库开发/构建:Node.js `^22.18.0 || >=24.11.0` +- 生成工程和本仓库统一使用 pnpm + +## 快速开始 ```bash -npm install -g @disdjj/acplugin +pnpm dlx @tokenroll/acplugin init my-plugin --yes +cd my-plugin +pnpm install +pnpm build ``` -或直接使用 `npx`: +也可以在空工程中安装框架和需要的 Platform: ```bash -npx @disdjj/acplugin convert . +pnpm add -D @tokenroll/acplugin \ + @tokenroll/acplugin-platform-claude-code \ + @tokenroll/acplugin-platform-codex ``` -## 快速开始 +```ts +// acplugin.config.ts +import { defineConfig } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: '可复用的 AI 工作流。', + platforms: [claudeCode(), codex()], +}); +``` -```bash -# 交互式引导 — 直接运行 acplugin! -acplugin +`init` 在没有传入 `--platform` 时会选择 Claude Code 和 Codex,但会显式写入两个 package 及其 import。运行时没有隐式 Platform:每次构建只使用 `platforms` 中的实例。 + +`acplugin.config.ts`、Hook descriptor 和 MCP descriptor 是由本地 Node.js 进程加载的可信工程代码,应按构建脚本同等标准审查。Migration 输入始终作为不可信数据处理,不会被当作规范 descriptor 执行。 + +## 工程目录 + +```text +my-plugin/ +├── acplugin.config.ts +├── package.json +├── public/ # 可选,复制到每个 Platform 根目录 +└── src/ + ├── commands/ + │ └── review.md + ├── skills/ + │ └── review/ + │ ├── SKILL.md + │ └── references/ + ├── agents/ + │ └── reviewer.md + ├── hooks/ # 仅启用 Hooks Extension 后使用 + │ └── policy/hook.ts + ├── mcp/ # 仅启用 MCP Extension 后使用 + │ └── docs/mcp.ts + └── runtime/ # 可选,由 Core 托管的 Node Runtime 源码 + ├── cli.ts # 一级文件按约定成为入口 + └── internal/helpers.ts # 嵌套文件作为普通依赖 +``` + +ID 和目录名使用小写 kebab-case。Markdown Component 必须包含 YAML Frontmatter 和非空正文。符号链接、逃逸工程根目录的路径会被拒绝。 + +ACPlugin 不提供 Instructions Component。仓库级 Instructions 属于宿主/工程配置,而不是可安装 Plugin 的能力边界。 + +## 配置 + +`acplugin.config.ts` 可以导出对象,也可以导出接收 `{ command, mode }` 的同步/异步函数。 + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; + +export default defineConfig(({ mode }) => ({ + name: 'team-review', + version: '1.0.0', + description: '团队代码审查工作流。', + displayName: 'Team Review', + platforms: [ + claudeCode(), + codex({ strict: mode === 'production' }), + ], + public: { + dir: 'public', + copy: [ + { from: 'assets', to: 'assets' }, + { from: 'NOTICE.md', to: 'NOTICE.md' }, + ], + }, + build: { + outDir: 'dist', + strict: true, + }, +})); +``` + +| 字段 | 含义 | +| --- | --- | +| `name/version/description` | 必填 Plugin 身份。 | +| `displayName` | 可选展示名称。 | +| `srcDir` | 规范化源码目录,默认 `src`。 | +| `public` | `false`、目录,或明确 copy 规则。 | +| `platforms` | 必填的非空列表,内容是显式导入的 Platform 实例。 | +| `runtime` | 内建 Node Runtime 约定、显式入口、编译参数或 `false`。 | +| `extensions` | Hooks、MCP 等可选横向能力。 | +| `build.outDir` | 托管输出目录,默认 `dist`。 | +| `build.strict` | 遇到 degraded/unsupported 是否失败,默认 `true`。 | + +官方 Platform 是以主包为 peer dependency 的独立 package: + +```ts +import antigravity from '@tokenroll/acplugin-platform-antigravity'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; +import cursor from '@tokenroll/acplugin-platform-cursor'; +import openCode from '@tokenroll/acplugin-platform-opencode'; +import pi from '@tokenroll/acplugin-platform-pi'; + +const platforms = [claudeCode(), codex(), cursor(), antigravity(), openCode(), pi()]; +``` + +Claude Code、Codex、Cursor 和 Antigravity 生成静态 Plugin Package;OpenCode 生成 Workspace Overlay;Pi 生成 npm Package。`acplugin init --platform ` 会显式安装并写入所选 package。主包不会重新导出官方集成、按 ID 发现 package,也不会在构建时安装依赖。 -# 转换当前项目 -acplugin convert . +## 核心 Components -# 从 GitHub 转换 -acplugin convert anthropics/claude-code --all --to cursor +### Skill -# 仅扫描资源(不转换) -acplugin scan anthropics/claude-code +```md +--- +description: 审查代码的正确性和可维护性。 +invocation: + user: true + model: true +requires: + agents: [reviewer] +--- +审查选定的改动并报告可执行的问题。 ``` -## 功能特性 +文件位置为 `src/skills/review/SKILL.md`。同目录下其他普通文件会作为 Skill 辅助资源复制。 + +### Command + +```md +--- +description: 审查指定改动。 +argumentHint: +requires: + skills: [review] +--- +使用 review Skill 审查 {{arguments}}。 +``` -- 转换 Skills、指令、MCP 配置、Agents、Commands 和 Hooks -- **5 个目标平台**:Codex CLI、OpenCode、Cursor、Google Antigravity、Pi -- 完整的 subagent 转换,为每个平台生成正确格式 -- 自动模型映射(Claude → GPT-5.4 / Gemini 3 Pro) -- 支持 Claude Code Plugin marketplace 格式(多插件仓库) -- 交互式 TUI,支持 checkbox 多选插件和平台 -- 直接支持 GitHub 仓库 — 无需先 clone -- 智能检测:自动识别本地项目、单插件和 marketplace 仓库 +文件位置为 `src/commands/review.md`。 -## 支持的转换 +### Agent -| 资源类型 | Codex CLI | OpenCode | Cursor | Antigravity | Pi | -|---------|-----------|----------|--------|-------------|----| -| **Skills** | `.agents/skills/` | `.opencode/skills/` | `.cursor/skills/` | `.agents/skills/` | `.pi/skills/` | -| **指令** | `AGENTS.md` | `AGENTS.md` | `.cursor/rules/*.mdc` | `GEMINI.md` | `AGENTS.md` | -| **MCP 服务器** | `.codex/config.toml` | `opencode.json` | `.cursor/mcp.json` | `.agents/mcp_config.json` | 不支持(警告) | -| **Agents** | `.codex/agents/*.toml` | `.opencode/agents/*.md` | `.cursor/agents/*.md` | `.agents/agents/*.md` | 不支持(警告) | -| **Commands** | 转换为 Skills | `.opencode/commands/` | `.cursor/commands/` | 转换为 Skills | `.pi/prompts/*.md` | -| **Hooks** | 记录在 `AGENTS.md` | 记录在 `AGENTS.md` | 仅输出警告 | 仅输出警告 | 仅输出警告 | +```md +--- +description: 专注的只读代码审查者。 +model: capable +capabilities: [filesystem:read, search] +--- +检查改动和证据,只报告可执行的问题。 +``` -Pi([pi-coding-agent](https://github.com/earendil-works/pi))是极简终端 harness,仅原生支持 Skills 与指令文件;Commands 降级为 prompt templates,MCP/Agents/Hooks 无对应格式(Pi 设计上通过 TypeScript extension 扩展),转换时输出警告。 +文件位置为 `src/agents/reviewer.md`。模型分级为 `inherit/fast/capable`;Capabilities 是语义声明,而不是目标平台工具名。 -### 模型映射 +Components 可以依赖 Skills 和 Agents。缺失依赖、自依赖和循环依赖都会导致构建失败。 -| Claude Code | → Codex | → Antigravity | -|-------------|---------|---------------| -| `sonnet` / `opus` | `gpt-5.6-sol` | `gemini-3.1-pro-preview` | -| `haiku` | `gpt-5.6-terra` | `gemini-3.6-flash` | -| (未指定) | `gpt-5.6-sol` | `gemini-3.1-pro-preview` | +## 平台兼容性 -OpenCode、Cursor 和 Pi 保持原始模型值不映射。 +| Component | Claude Code | Codex | Cursor | Antigravity | OpenCode | Pi | +| --- | --- | --- | --- | --- | --- | --- | +| Skill | 原生 | 原生 | 原生 | 原生 | 原生 | 原生 | +| Command | 原生 | 转换为 Skill | 原生 | 转换为 Skill | 原生 | 转换为 Prompt | +| Agent | 原生 | 降级 Skill | 原生但有字段级限制 | 降级 Skill | 原生并转换能力字段 | 降级 Skill | -## CLI 参考 +Codex 可安装 Plugin 不能注册自定义的工程/用户 Agent。因此包含 Agent 时,严格 Codex 构建会失败;只有明确接受 fallback 及其结构化告警时,才应配置 `codex({ strict: false })`。 -### `acplugin scan [source]` +Package 形态、全部可移植 Hook 事件和 MCP 传输支持请查看[完整兼容矩阵](./llmdoc/reference/conversion-matrix.zh-CN.md)。 -扫描并列出可转换的资源。 +## Hooks Extension ```bash -acplugin scan . # 当前目录 -acplugin scan ./my-project # 本地路径 -acplugin scan anthropics/claude-code # GitHub 仓库 -acplugin scan https://github.com/owner/repo # 完整 GitHub URL -acplugin scan owner/repo --path plugins/foo # 仓库内子路径 +pnpm add -D @tokenroll/acplugin-extension-hooks +``` + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import hooks from '@tokenroll/acplugin-extension-hooks'; + +export default defineConfig({ + name: 'policy-plugin', + version: '1.0.0', + description: '可移植策略 Hooks。', + platforms: [claudeCode()], + extensions: [hooks()], +}); ``` -### `acplugin convert [source]` +```ts +// src/hooks/policy/hook.ts +import type { Hook } from '@tokenroll/acplugin-extension-hooks'; + +export default { + event: 'PreToolUse', + matcher: 'Bash', + timeout: 5, + async run(input) { + return input.cwd + ? { decision: 'allow' } + : { decision: 'deny', reason: '缺少工作目录。' }; + }, +} satisfies Hook<'PreToolUse'>; +``` -将 Claude Code 插件转换为目标平台格式。 +11 个可移植事件: -```bash -acplugin convert . # 交互式选择平台 -acplugin convert . --to cursor # 指定平台 -acplugin convert . --to codex,antigravity # 多个平台 -acplugin convert anthropics/claude-code # 从 GitHub,交互式 -acplugin convert anthropics/claude-code --all # 全部插件,跳过选择 -acplugin convert . -o ./output # 自定义输出目录 -acplugin convert . --dry-run # 预览模式,不写入文件 +```text +SessionStart, SessionEnd, UserPromptSubmit, PreToolUse, PermissionRequest, +PostToolUse, PreCompact, PostCompact, SubagentStart, SubagentStop, Stop ``` -**选项:** +20 个 Claude Code-only 事件保持显式平台限定,不影响 Codex 兼容性: + +```text +Setup, UserPromptExpansion, PermissionDenied, PostToolUseFailure, PostToolBatch, +Notification, MessageDisplay, TaskCreated, TaskCompleted, StopFailure, +TeammateIdle, InstructionsLoaded, ConfigChange, CwdChanged, DirectoryAdded, +FileChanged, WorktreeCreate, WorktreeRemove, Elicitation, ElicitationResult +``` -| 选项 | 说明 | -|------|------| -| `-t, --to ` | 目标平台(逗号分隔:`codex`、`opencode`、`cursor`、`antigravity`、`pi`) | -| `-o, --output ` | 输出目录 | -| `-a, --all` | 全部转换,跳过交互选择 | -| `-p, --path ` | 仓库内子路径 | -| `--dry-run` | 预览生成的文件,不实际写入 | +使用 `event: { platform: 'claude-code', name: 'Setup' }` 声明;裸字符串 `'Setup'` 会被拒绝。 -## 使用示例 +ACPlugin 把每个实现只 bundle 一次,生成自包含、平台中立的 Node 20 ESM Handler;经过验证的平台 wire profile 会一同编译进该 Bundle,负责原生输入校验、递归 camelCase 转换、root/data 映射和输出映射,不再依赖相邻运行时 JavaScript。共享 Handler 负责有界 JSON I/O、语义结果校验、安全错误和确定性的第三方许可证产物。宿主忽略的 meaningful matcher 会按具体 Hook 报告 `degraded`;不支持的事件不会生成伪运行时。 -### 转换本地项目 +## MCP Extension ```bash -cd my-project -acplugin convert . --to cursor,antigravity +pnpm add -D @tokenroll/acplugin-extension-mcp ``` -### 从 GitHub Plugin Marketplace 转换 +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import mcp from '@tokenroll/acplugin-extension-mcp'; + +export default defineConfig({ + name: 'tools-plugin', + version: '1.0.0', + description: '可移植 MCP 工具。', + platforms: [claudeCode()], + extensions: [mcp()], +}); +``` -```bash -# 交互式:浏览并选择插件 -acplugin convert anthropics/claude-code +远程 Streamable HTTP: + +```ts +// src/mcp/docs/mcp.ts +import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; + +export default { + transport: 'http', + url: 'https://example.com/mcp', + auth: { type: 'bearer', env: 'DOCS_TOKEN' }, + headers: { 'X-Tenant': { env: 'TENANT_ID' } }, +} satisfies McpServer; +``` + +本地 stdio: -# 全部插件转换到所有平台 -acplugin convert anthropics/claude-code --all -o ./converted +```ts +// src/mcp/local-tools/mcp.ts +import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; + +export default { + transport: 'stdio', + entry: 'server.ts', + env: { API_TOKEN: { env: 'LOCAL_API_TOKEN' } }, +} satisfies McpServer; ``` -### 扫描仓库查看可用资源 +本地 MCP 需要由作者提供完整的 stdio MCP 实现,ACPlugin 将其 bundle 为 Node 20 ESM。development 与 production 构建都会拒绝无法静态解析的运行时 dynamic import,只把声明的公开字面量环境值传给探测进程,并要求在超时和输出上限内完成 `initialize → initialized → tools/list` 协议 smoke;不会通过 mode 分支或缓存跳过该检查,任何 Secret 引用值也不会被读取。HTTP MCP 只需要声明远程 endpoint、认证和 Header 引用。生产 HTTP 必须使用 HTTPS,开发模式仅允许 loopback HTTP。 + +Claude Code、Codex 和 OpenCode 同时支持远程 HTTP 与 Bundle 后的本地 stdio;Cursor 与 Antigravity 只支持远程 HTTP;Pi 会报告 MCP 不支持。详见[完整兼容矩阵](./llmdoc/reference/conversion-matrix.zh-CN.md)。 + +## 内建 Node Runtime + +```ts +// acplugin.config.ts +export default defineConfig({ + // ...元数据与显式 Platforms + runtime: { + entries: { + cli: { entry: 'bin/cli.ts', kind: 'executable' }, + library: { entry: 'library.ts', kind: 'module' }, + }, + compile: { treeshake: true }, + }, +}); +``` + +省略 `runtime` 字段时,`src/runtime/` 下每个受支持的一级文件都会按约定成为可执行入口,嵌套文件仍作为普通依赖。显式 `runtime.entries` 会完整替换自动发现,`runtime: false` 则关闭该约定。每个入口会成为 `runtime//main.mjs` 下确定、自包含的 Node 20 ESM Bundle。npm 依赖进入 Bundle,只有 `node:` 内置模块保持 external;可执行入口 mode 为 `0755`,module 入口为 `0644`,需要时输出相邻第三方许可证。Core 只编译一次,Claude Code 与 Codex 继承同一份 framework-owned 字节;没有稳定本地 Node/Plugin Root 契约的平台报告 `unsupported`,且不生成替代 Asset。类型检查仍由工程自己的 `tsc --noEmit` 负责。 + +## Extension 生命周期 + +```text +config → setup Sessions → discover Resources → Canonical Project +→ validate → compile → Platform base Package → Contributors → Core merge +→ finalize → materialize/validate candidates → Distributions +→ compatibility → transaction → reverse close +``` + +Descriptor 通过 `context.modules` 加载,可执行产物通过 Core 统一的 `context.compiler` Rolldown Service 构建;模块、许可证、Plugin 与 tsconfig 依赖图会自动进入 `dev` 监听。集成只获得 owner-scoped 能力,不得维护私有 bundler,也不能直接写 `dist`。Platform Contributor 从同一份不可变 base Package 返回自有 Asset、声明的 Document extension point 字段和兼容性,不能替换 Platform 输出或观察其他 Extension state。Session `close` 始终按初始化逆序执行。 + +## CLI + +```text +acplugin init [directory] +acplugin dev +acplugin validate +acplugin inspect +acplugin build +acplugin migrate [destination] +``` + +通用参数包括 `--config`、`--platform`、`--mode` 和 `--json`。兼容性严格度通过 `acplugin.config.ts` 中的 `build.strict` 或 Platform factory override 声明。 + +- `validate`:完整生成并验证 Platform,但不写 `dist`。 +- `inspect`:额外返回 Package/Asset 详情,但不写 `dist`。 +- `build`:所有 Platform 成功后才原子替换完整 `dist`。 +- `dev`:监听配置、Core Module/Build Service 的真实模块图、Components、Public、descriptor,以及 bundler/Plugin/license/tsconfig 依赖;Package 依赖按解析后的 package root 监听。每批新 watcher ready 后先补偿构建,失败时保留上次成功产物,修复后恢复构建。对托管的可执行 Bundle,Rolldown 无法纳入静态模块图的运行时计算 import 会直接被拒绝。 +- 裸 `acplugin` 只打印 Help,不发起交互。 + +退出码:`0` 成功、`1` 工程/构建/Migration 失败、`2` CLI 用法或框架内部失败、`130` 取消。非 watch 命令的 JSON 模式只向 stdout 输出一个带版本的文档。 + +## 确定性与安全 + +- Asset 是 owner-scoped 不可变引用,报告包含 mode、size、SHA-256 和结构化 origin。 +- 拒绝绝对/穿越路径、符号链接、大小写/Unicode 冲突和未授权来源。 +- 构建使用同文件系统 stage、锁、事务记录、备份和完整目录 swap。 +- 任意 Platform 失败都会保留上次完整 `dist`。 +- 生成内容/报告不包含时间戳、临时路径、环境变量值或凭据。 +- 未启用对应 Extension 时,`src/hooks` 或 `src/mcp` 中存在内容会直接报错;`src/runtime` 由 Core 直接拥有。 + +## 旧版本 Migration + +Migration 只属于 CLI,采用动态加载,并与 Core/Platform/正常启动路径隔离。 ```bash -$ acplugin scan anthropics/claude-code +acplugin migrate ./legacy-project ./new-plugin \ + --name new-plugin \ + --description "迁移后的 Plugin" + +acplugin migrate owner/repository ./new-workspace --all +``` + +支持本地 Claude 工程、单 Plugin、Marketplace 和 GitHub 来源。`--plugin ` 会把一个规范工程直接写到目标根;只有 `--all` 才创建由独立工程组成的 pnpm workspace。Skills、Commands、Agents 和可移植远程 HTTP MCP 会尽量映射;Instructions、原始 Hooks、Hook 实现文件、本地外部命令 MCP 和不支持的资源保存在 `.acplugin-migration/unmapped/`,同时生成稳定报告和人工处理项。生成工程在原子提交前会经过公开配置加载、真实 Core Module Service、Scanner、生命周期和隔离的 Migration Validator;安装依赖后再由正式 Platform/Extension 完成完整语义验证。Migration 不允许原地写入。 + +`--dry-run` 不写目标目录;`--strict` 在出现 degraded/unmapped 时失败。 + +## 文档工程与 Playground + +除公开包外,仓库还包含两个仅供仓库使用的私有 workspace: -Claude Code Plugin Marketplace -✔ Found 13 plugin(s) with resources +- `packages/docs` 是 VitePress 文档站,按 Guide、Config、Platform、Extension、Ecosystem、Playground 和 Resources 组织内容。每次启动或构建文档前,TypeDoc 都会为九个公开 package 根入口重新生成 API 页面和 sidebar。 +- `packages/playground` 是一个领域中立的六平台/Hooks/MCP/Node Runtime 全能力模板。它验证规范 Commands、带辅助资源的 Skill、Agents、全部 portable Hook 事件、HTTP 与本地 MCP、Node Runtime、Public 文件和 Claude Code/Codex Marketplace,不实现特定产品业务。 -1. agent-sdk-dev [development] — 3 resource(s) -2. code-review [productivity] — 1 resource(s) -3. commit-commands [productivity] — 3 resource(s) -... +```bash +pnpm run docs:dev # 生成 API 页面并启动 VitePress +pnpm run docs:build # 生成 API 页面并构建静态站点 +pnpm run docs:check # 检查文档结构/构建和真实 Playground ``` -### 私有仓库 +自动生成的 API Markdown/sidebar、VitePress cache/产物和 Playground `dist` 都可重建,并由 Git 忽略。 + +## 包与仓库开发 + +公开包: + +- `@tokenroll/acplugin` +- `@tokenroll/acplugin-platform-claude-code` +- `@tokenroll/acplugin-platform-codex` +- `@tokenroll/acplugin-platform-cursor` +- `@tokenroll/acplugin-platform-antigravity` +- `@tokenroll/acplugin-platform-opencode` +- `@tokenroll/acplugin-platform-pi` +- `@tokenroll/acplugin-extension-hooks` +- `@tokenroll/acplugin-extension-mcp` -设置 `GITHUB_TOKEN` 环境变量访问私有仓库: +官方集成使用与第三方 package 相同的公开 lifecycle SDK,并把主包声明为 peer dependency。Core、Vitest Test workspace、Docs 和 Playground 保持私有;Core 会内联进主包,任何公开运行时清单都不得包含 `@acplugin/*`。 ```bash -export GITHUB_TOKEN=ghp_xxx -acplugin convert my-org/private-plugins --all --to codex +pnpm install +pnpm run check +pnpm run docs:check ``` -## 工作原理 +PR 会分别触发 Lint 与 Typecheck Action。带有 Changeset 的功能 PR 合并到 `main` 后,Changelog Action 会消费待处理 Changeset,并创建或更新包含独立 package 版本升级和 changelog 的版本 PR;它绝不发布 package。 -1. **扫描** — 检测 Claude Code 资源:`.claude/` 项目结构、`.claude-plugin/` 插件格式或 marketplace 仓库 -2. **选择** — 交互式 TUI 让你选择要转换的插件和目标平台 -3. **转换** — 将每个资源转换为目标平台格式,自动映射模型和字段 -4. **报告** — 显示生成结果,对无法完全转换的资源输出警告 +手工触发的 Release Action 只发布稳定 semver 版本到 npm `latest`,不会创建 Tag 或 GitHub Release。beta 仍由获得授权的维护者在本地发布: + +```bash +pnpm run publish:beta:dry-run +pnpm run publish:beta +``` -Claude 特有的功能(如 `context: fork`、`agent: Explore`)会以 HTML 注释的形式保留在输出文件中,供参考。 +beta 与稳定版命令均先构建一次,再使用 pnpm 的递归公开 workspace 发布流程;pnpm 会在打包 manifest 中改写 `workspace:^` peer range。九个 package 独立版本化;除非另获授权,不要创建 Tag 或 GitHub Release。 -## 许可证 +## License MIT diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..42280ed --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,30 @@ +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import stylistic from '@stylistic/eslint-plugin'; + +export default tseslint.config( + { + ignores: [ + '**/dist', + '**/node_modules', + 'llmdoc', + 'coverage', + '.llmdoc-tmp', + 'packages/docs/api', + 'packages/docs/.vitepress/cache', + ], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + // Formatting via ESLint Stylistic, matched to the existing code style. + stylistic.configs.customize({ indent: 2, quotes: 'single', semi: true, braceStyle: '1tbs' }), + { + rules: { + // This tool parses arbitrary community YAML/JSON, so `any` is unavoidable. + '@typescript-eslint/no-explicit-any': 'off', + // Isolated tolerant Migration sources retain compatibility with legacy inputs. + '@typescript-eslint/no-require-imports': 'off', + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], + }, + }, +); diff --git a/llmdoc/architecture/decisions/0001-lifecycle-determinism-and-cache.md b/llmdoc/architecture/decisions/0001-lifecycle-determinism-and-cache.md new file mode 100644 index 0000000..1b69043 --- /dev/null +++ b/llmdoc/architecture/decisions/0001-lifecycle-determinism-and-cache.md @@ -0,0 +1,44 @@ +# ADR-0001: Session close, deterministic inputs, and dev rebuild scope + +- Status: Accepted +- Date: 2026-08-08 +- Updated: 2026-08-14 +- Applies to: Kernel v2, lifecycle API v1 + +## Context + +Kernel v2 replaces shared lifecycle hooks with one private `PlatformSession` or `ExtensionSession` per build. A Session may hold resources that must be released on success, failure, or abort, while a managed commit must remain rollback-capable until all required Session cleanup succeeds. + +Build output and schema-v3 reports must also remain deterministic and free of machine paths or secrets. The former lifecycle exposed an ambient environment snapshot and discussed a cross-run dev cache without defining serializable state, implementation fingerprints, replayable effects, or transaction semantics. + +## Decision + +1. Core creates one isolated Session for every initialized Platform and Extension and calls `close()` exactly once in reverse initialization order. +2. For committed builds, `close()` runs from the transaction's `afterSwap` window. A close failure records a `cleanup` diagnostic, rolls back the new output, and leaves `success=false` and `committed=false`. +3. For validate, inspect, failed builds, and aborted development Sessions, Core closes initialized integrations outside the commit with `committed=false`. Cleanup continues after an individual close failure and never replaces the first business failure in the close summary. +4. Integration lifecycle contexts do not receive ambient `process.env`. Functional config sees only `{ command, mode }`; isolated execution receives only an explicit caller-supplied environment through `ExecutionService`. +5. Core and official integrations must not introduce time, randomness, machine paths, temporary paths, or secret values into Assets or reports. Sanitization removes structured secrets, recognized credentials, and known physical roots; it does not rewrite arbitrary substrings using environment values. +6. `Project.dev()` performs a complete BuildSession for every coalesced change round and preserves the last successful output. Kernel v2 has no whole-execution or cross-run cache. +7. Any future cache requires a versioned fingerprint, serializable owner-scoped state and effects, complete dependency discovery, corruption recovery, and clean-build equivalence tests. Third-party integrations are uncacheable unless a future explicit contract says otherwise. + +## Consequences + +- A successful report cannot contain an error diagnostic. +- A failed cleanup cannot expose a partially committed target set. +- Development rebuilds keep lifecycle, dependency discovery, validation, and transaction behavior equivalent to clean builds. +- Integration authors cannot accidentally depend on an environment snapshot that the framework cannot audit. + +## Rejected alternatives + +- Closing after the transaction is no longer rollback-capable: this could leave new output with a failed build result. +- Returning a previous report on a dev cache hit: this skips lifecycle effects, dependency discovery, recovery, and current-output validation. +- Replacing every environment-value substring in diagnostics: unrelated values can corrupt stable protocol identities. +- Allowing cleanup failure to overwrite the first business failure: it hides the actionable cause and makes diagnostics order-dependent. + +## Evidence + +- `packages/core/src/lifecycle/build-session.ts` +- `packages/core/src/lifecycle/dev-session.ts` +- `packages/core/src/security/report-safety.ts` +- `packages/core/src/output/transaction.ts` +- `packages/core/src/contracts/` (`IntegrationCloseContext`, `ExecutionService`, `BuildReport`) diff --git a/llmdoc/architecture/decisions/0001-lifecycle-determinism-and-cache.zh-CN.md b/llmdoc/architecture/decisions/0001-lifecycle-determinism-and-cache.zh-CN.md new file mode 100644 index 0000000..6876289 --- /dev/null +++ b/llmdoc/architecture/decisions/0001-lifecycle-determinism-and-cache.zh-CN.md @@ -0,0 +1,44 @@ +# ADR-0001:Session 关闭、确定性输入与 dev 重建范围 + +- 状态:已接受 +- 日期:2026-08-08 +- 更新:2026-08-14 +- 适用范围:Kernel v2、lifecycle API v1 + +## 背景 + +Kernel v2 用每次构建独占的 `PlatformSession` / `ExtensionSession` 取代共享生命周期 Hook。Session 可能持有必须在成功、失败或中止后释放的资源;同时,托管提交在必要的 Session 清理全部成功前必须仍可回滚。 + +构建输出和 schema-v3 报告还必须保持确定性,并且不泄漏机器路径或 Secret。旧生命周期曾暴露环境快照,也讨论过跨轮次 dev cache,但没有定义可序列化状态、实现指纹、可重放副作用和事务语义。 + +## 决策 + +1. Core 为每个已初始化的 Platform/Extension 创建隔离 Session,并按初始化逆序恰好调用一次 `close()`。 +2. 对需要提交的构建,`close()` 在事务的 `afterSwap` 可回滚窗口运行。关闭失败会记录 `cleanup` 诊断、恢复旧输出,并得到 `success=false`、`committed=false`。 +3. `validate`、`inspect`、失败构建和被中止的开发 Session 在事务外关闭已初始化集成,`committed=false`。单个关闭失败不阻止其余关闭,也不能覆盖 close 摘要中的首个业务失败。 +4. 集成生命周期 Context 不接收环境变量快照。函数式配置只能观察 `{ command, mode }`;隔离执行只通过 `ExecutionService` 接收调用方显式提供的最小环境。 +5. Core 和官方集成不得把时间、随机、机器路径、临时路径或 Secret 值写入 Asset/报告。脱敏只处理结构化 Secret、可识别凭据和已知物理根,不用任意环境值做无边界子串替换。 +6. `Project.dev()` 对每个合并后的变更轮次执行完整 BuildSession,并保留最后一次成功输出。Kernel v2 不提供整条执行或跨轮次缓存。 +7. 未来缓存必须先定义版本化 fingerprint、可序列化的 owner-scoped 状态与副作用、完整依赖发现、损坏恢复和 clean-build 等价测试。第三方集成默认不可缓存,除非未来协议显式允许。 + +## 影响 + +- 成功报告不能同时包含 error 诊断。 +- cleanup 失败不能暴露部分提交的目标集合。 +- dev 重建与 clean build 保持生命周期、依赖发现、校验和事务语义一致。 +- 集成作者不能意外依赖框架无法审计的环境快照。 + +## 未采用方案 + +- 在事务失去回滚能力后才关闭 Session:可能留下“新输出已生效、构建却失败”的状态。 +- dev cache 命中时直接返回旧报告:会跳过生命周期副作用、依赖发现、恢复和当前输出校验。 +- 用所有环境值替换诊断中的同字子串:无关值可能破坏稳定协议身份。 +- 让 cleanup 失败覆盖首个业务失败:会隐藏可行动原因,并让诊断依赖执行顺序。 + +## 证据 + +- `packages/core/src/lifecycle/build-session.ts` +- `packages/core/src/lifecycle/dev-session.ts` +- `packages/core/src/security/report-safety.ts` +- `packages/core/src/output/transaction.ts` +- `packages/core/src/contracts/`(`IntegrationCloseContext`、`ExecutionService`、`BuildReport`) diff --git a/llmdoc/architecture/decisions/0002-extension-contribution-order.md b/llmdoc/architecture/decisions/0002-extension-contribution-order.md new file mode 100644 index 0000000..aa3bcce --- /dev/null +++ b/llmdoc/architecture/decisions/0002-extension-contribution-order.md @@ -0,0 +1,42 @@ +# ADR-0002: Extension contributions are unordered and centrally merged + +- Status: Accepted +- Date: 2026-08-08 +- Updated: 2026-08-14 +- Applies to: Kernel v2, lifecycle API v1 + +## Context + +An Extension builds one Platform-independent immutable state and may publish a `PlatformContributor` for each supported Platform. If Contributors mutated a shared package or observed earlier contributions, configuration order would become an implicit dependency and conflict resolution would degrade into first-writer-wins behavior. + +Kernel v2 instead needs independent integrations, parallel-safe collection, and deterministic conflicts. + +## Decision + +1. A Platform creates one frozen base Package. Every matching Framework and Extension Contributor reads that same base snapshot. +2. A Contributor cannot observe another Contribution, another Extension's state, or a mutable Package. +3. Core collects Extension Contributions concurrently, binds each one to its owner, sorts the collection by stable owner identity, and performs one centralized add-only merge. +4. Contributions may fill declared empty Document extension points, add owner-authorized Assets, and report compatibility. They cannot replace or delete base content, append to undeclared fields, claim Components, or override another owner. +5. Duplicate Document fields, Asset paths, compatibility tuples, or normalization-equivalent paths fail deterministically. Configuration order is not a conflict-resolution mechanism. +6. Lifecycle API v1 does not add `order`, `enforce`, an Extension dependency graph, cross-Extension state access, or claim/suppress protocols. + +## Consequences + +- Reordering independent Extensions does not change successful Package bytes. +- Contributor collection can run concurrently without changing semantics. +- Conflicts are explicit architecture errors rather than order-sensitive output. +- Features that truly require cooperation must be represented by a shared Framework contract or a Platform extension point, not hidden Extension sequencing. + +## Rejected alternatives + +- Serial mutation in configuration order: creates an undocumented dependency graph and observable partial state. +- `enforce: 'pre' | 'post'`: adds ordering vocabulary without defining safe data dependencies. +- Last-writer-wins merge: violates owner isolation and hides incompatible integrations. +- Direct Platform replacement or Component suppression: expands the authority model beyond additive integration. + +## Evidence + +- `packages/core/src/resources/extensions.ts` +- `packages/core/src/package/registry.ts` +- `packages/core/src/lifecycle/build-session.ts` +- `packages/core/src/contracts/` (`PlatformContributor`, `ContributionContext`, `PackageContribution`) diff --git a/llmdoc/architecture/decisions/0002-extension-contribution-order.zh-CN.md b/llmdoc/architecture/decisions/0002-extension-contribution-order.zh-CN.md new file mode 100644 index 0000000..6f5b59b --- /dev/null +++ b/llmdoc/architecture/decisions/0002-extension-contribution-order.zh-CN.md @@ -0,0 +1,42 @@ +# ADR-0002:Extension Contribution 无序并由 Core 集中合并 + +- 状态:已接受 +- 日期:2026-08-08 +- 更新:2026-08-14 +- 适用范围:Kernel v2、lifecycle API v1 + +## 背景 + +Extension 只构建一次与 Platform 无关的不可变状态,并可为每个支持的平台提供一个 `PlatformContributor`。如果 Contributor 修改共享 Package 或观察前序 Contribution,配置顺序就会成为隐式依赖,冲突也会退化为 first-writer-wins。 + +Kernel v2 需要的是相互独立的集成、可并行收集和确定性冲突。 + +## 决策 + +1. Platform 创建一份冻结的 base Package;所有匹配的 Framework/Extension Contributor 都读取同一个 base snapshot。 +2. Contributor 不能观察其他 Contribution、其他 Extension state 或可变 Package。 +3. Core 并发收集 Extension Contribution,为每条贡献绑定 owner,按稳定 owner 身份排序,再执行一次集中式 add-only merge。 +4. Contribution 只能填写已声明且为空的 Document extension point、追加 owner 已授权的 Asset,并报告兼容性;不能替换或删除 base 内容、写入未声明字段、接管 Component 或覆盖其他 owner。 +5. 重复 Document 字段、Asset 路径、兼容性 tuple,以及大小写/Unicode 归一化等价路径都会确定性失败。配置顺序不是冲突解决机制。 +6. lifecycle API v1 不增加 `order`、`enforce`、Extension 依赖图、跨 Extension state 访问或 claim/suppress 协议。 + +## 影响 + +- 调整相互独立的 Extension 顺序不会改变成功 Package 的字节。 +- Contributor 可以并发收集而不改变语义。 +- 冲突是显式架构错误,不会产生依赖顺序的输出。 +- 真正需要协作的能力必须进入共享 Framework contract 或 Platform extension point,不能隐藏在 Extension 顺序中。 + +## 未采用方案 + +- 按配置顺序串行修改:会产生未声明依赖图和可观察的部分状态。 +- `enforce: 'pre' | 'post'`:增加排序词汇,却没有定义安全的数据依赖。 +- last-writer-wins:破坏 owner 隔离,并掩盖互不兼容的集成。 +- 直接替换 Platform 或 suppress Component:把权限模型扩张到 additive integration 之外。 + +## 证据 + +- `packages/core/src/resources/extensions.ts` +- `packages/core/src/package/registry.ts` +- `packages/core/src/lifecycle/build-session.ts` +- `packages/core/src/contracts/`(`PlatformContributor`、`ContributionContext`、`PackageContribution`) diff --git a/llmdoc/architecture/decisions/0003-node-toolchain-and-runtime-support.md b/llmdoc/architecture/decisions/0003-node-toolchain-and-runtime-support.md new file mode 100644 index 0000000..c06d3c0 --- /dev/null +++ b/llmdoc/architecture/decisions/0003-node-toolchain-and-runtime-support.md @@ -0,0 +1,43 @@ +# ADR-0003: Separate repository toolchain and published runtime support + +- Status: Accepted +- Date: 2026-08-08 +- Applies to: ACPlugin 1.0 + +## Context + +The repository build tool and the published packages have different Node.js constraints. tsdown 0.22.14 requires `^22.18.0 || >=24.11.0`, while ACPlugin intends to keep a supported Node 20 runtime. The previous Commander 15 dependency prevented that intent because it requires Node 22.12 or newer. Other direct runtime dependencies also require precise minor ranges rather than the broad `>=20` declaration. + +## Decision + +1. Repository development, build, and release verification use `^22.18.0 || >=24.11.0`; the standard CI version is 22.18.0. +2. The CLI pins Commander 14.0.1, whose engine range still includes Node 20. Existing CLI behavior is protected by subprocess tests. +3. All public packages declare the intersection supported by their current direct runtime dependencies: `^20.19.0 || ^22.13.0 || >=23.5.0`. +4. Generated Hooks/MCP code and package bundles retain the `node20` target. `@types/node` remains on the Node 20.19 API baseline. +5. Private package manifests are not mass-rewritten to the repository toolchain range. Core is not published and its emitted code remains part of the Node 20-targeted main-package bundle. +6. The repository Actions use Node 22.18 while published package manifests keep the separately declared Node 20.19-compatible runtime range. + +## Consequences + +- Node 20 support is expressed by the package runtime range rather than the repository build-tool range. +- Contributors use the Node version required by the build tool without forcing every consumer to use it. +- Runtime dependency upgrades must re-check the public engine intersection. +- Commander 15 features cannot be used while Node 20 remains supported; such an upgrade requires a new runtime-floor decision. + +## Rejected alternatives + +- Keeping every manifest at `>=20`: claims support for versions rejected by direct dependencies. +- Raising all public packages to Node 22.18: unnecessarily couples consumers to the repository build tool. +- Keeping Commander 15 while claiming Node 20 support: internally contradictory. +- Installing the full workspace on Node 20 in CI: exercises unsupported dev tooling instead of the published runtime. + +## Evidence + +- Root `package.json:7-9,29-44` +- `packages/acplugin/package.json:11,31-57` +- `packages/platforms/*/package.json:11` +- `packages/extensions/hooks/package.json:11,21-28` +- `packages/extensions/mcp/package.json:11,20-27` +- `.github/workflows/lint.yml` +- `.github/workflows/typecheck.yml` +- Locked manifests: tsdown 0.22.14, Commander 14.0.1, Chokidar 5.0.0, Rolldown 1.2.2, and `@inquirer/prompts` 8.5.2 diff --git a/llmdoc/architecture/decisions/0003-node-toolchain-and-runtime-support.zh-CN.md b/llmdoc/architecture/decisions/0003-node-toolchain-and-runtime-support.zh-CN.md new file mode 100644 index 0000000..d57f71d --- /dev/null +++ b/llmdoc/architecture/decisions/0003-node-toolchain-and-runtime-support.zh-CN.md @@ -0,0 +1,43 @@ +# ADR-0003:分离仓库工具链与公开运行时支持 + +- 状态:已接受 +- 日期:2026-08-08 +- 适用版本:ACPlugin 1.0 + +## 背景 + +仓库构建工具与已发布包具有不同的 Node.js 约束。tsdown 0.22.14 要求 `^22.18.0 || >=24.11.0`,而 ACPlugin 希望保留受支持的 Node 20 运行时。先前的 Commander 15 要求 Node 22.12 或更高,因此与该产品目标冲突。其他直接运行依赖也要求精确 minor 范围,不能用宽泛的 `>=20` 准确表达。 + +## 决策 + +1. 仓库开发、构建和发布验证使用 `^22.18.0 || >=24.11.0`;标准 CI 版本为 22.18.0。 +2. CLI 固定 Commander 14.0.1,该版本的 engine 仍包含 Node 20;现有 CLI 行为由子进程测试保护。 +3. 全部公开 package 声明当前直接运行依赖的支持交集:`^20.19.0 || ^22.13.0 || >=23.5.0`。 +4. 生成的 Hooks/MCP 代码与 package bundle 保持 `node20` target;`@types/node` 保持 Node 20.19 API 基线。 +5. 不把私有 package manifest 批量改成仓库工具链范围。Core 不发布,其 emitted code 最终属于以 Node 20 为目标的主包 bundle。 +6. 仓库 Action 使用 Node 22.18;已发布 package manifest 继续声明独立的 Node 20.19 兼容运行时范围。 + +## 影响 + +- Node 20 支持由 package 运行时范围表达,而不受仓库构建工具范围牵连。 +- 贡献者使用构建工具所需版本,但消费者无需被迫跟随仓库工具链。 +- 升级运行依赖时必须重新检查公开 engine 交集。 +- 保留 Node 20 期间不能使用 Commander 15 专属能力;升级需要新的 runtime-floor 决策。 + +## 未采用方案 + +- 所有 manifest 保持 `>=20`:会声称支持直接依赖明确拒绝的版本。 +- 全部公开 package 都抬到 Node 22.18:把消费者无谓绑定到仓库构建工具。 +- 保留 Commander 15 同时声称支持 Node 20:内部矛盾。 +- 在 Node 20 CI 安装整个 workspace:验证的是不受支持的 dev 工具链,而不是公开运行时。 + +## 证据 + +- 根 `package.json:7-9,29-44` +- `packages/acplugin/package.json:11,31-57` +- `packages/platforms/*/package.json:11` +- `packages/extensions/hooks/package.json:11,21-28` +- `packages/extensions/mcp/package.json:11,20-27` +- `.github/workflows/lint.yml` +- `.github/workflows/typecheck.yml` +- lock 中固定的 tsdown 0.22.14、Commander 14.0.1、Chokidar 5.0.0、Rolldown 1.2.2 与 `@inquirer/prompts` 8.5.2 manifest diff --git a/llmdoc/architecture/decisions/0004-first-class-platform-packages.md b/llmdoc/architecture/decisions/0004-first-class-platform-packages.md new file mode 100644 index 0000000..7510b45 --- /dev/null +++ b/llmdoc/architecture/decisions/0004-first-class-platform-packages.md @@ -0,0 +1,46 @@ +# ADR-0004: Platforms are first-class ecosystem packages + +- Status: accepted +- Date: 2026-08-08 +- Scope: ACPlugin 1.0 package API + +## Context + +The six official Platforms were private `@acplugin/*` workspace packages bundled and re-exported by the main package. That model simplified single-tarball use, but gave official Platforms a private Core dependency unavailable to third parties and forced the framework package to know every official implementation. Extensions already demonstrate that an independently published package can use the public lifecycle SDK through a peer dependency while preserving ownership, branding, and lifecycle boundaries. + +Making `@tokenroll/acplugin/platforms/` an export subpath would still leave it owned and versioned by the main package rather than create an independent installation and publication boundary. + +## Decision + +1. `@tokenroll/acplugin` provides only the CLI and public framework SDK; it does not re-export official Platforms or Extensions. +2. Each official Platform is published as `@tokenroll/acplugin-platform-`. The Extensions retain `@tokenroll/acplugin-extension-`. +3. Every official integration imports only public contracts from `@tokenroll/acplugin/sdk` and declares the main package as a peer dependency. Production sources cannot import private Core. +4. `platforms` is required. The main package does not load official implementations by default or by ID. `init` preserves the default Claude Code and Codex experience by generating explicit dependencies and imports. +5. Official integrations are versioned independently; lifecycle `apiVersion` and the main-package peer range express compatibility. +6. Third-party packages need no registry, official scope, or enforced naming convention. +7. Version 1.0 keeps no compatibility re-export or Platform subpath. + +## Consequences + +- Projects install and import every selected Platform explicitly. +- Official Platforms become real examples that third-party authors can reproduce. +- The normal main-package runtime graph does not grow with the official Platform catalog. +- Release verification expands from three to nine tarballs and checks peer rewriting, brand interoperability, and a clean consumer. +- Init, Migration, fixtures, documentation, and release workflows must use the independent package names. + +## Rejected alternatives + +- Main-package `./platforms/*` subpaths: they retain one owner, version, and publication boundary. +- Deprecated re-exports: they preserve the wrong default and prevent a genuinely narrow framework package. +- Automatic package discovery or installation by Platform ID: it introduces network side effects and non-deterministic naming resolution. +- Publishing the private Core package: it leaks Registry and transaction internals instead of maintaining one public SDK boundary. + +## Evidence + +- `packages/acplugin/src/index.ts` +- `packages/acplugin/src/author/project.ts` +- `packages/acplugin/src/sdk.ts` +- `packages/acplugin/tsdown.config.ts` +- `packages/platforms/*/package.json` +- `packages/extensions/*/package.json` +- Specification §4.2–§4.4, §5.2, and §19 diff --git a/llmdoc/architecture/decisions/0004-first-class-platform-packages.zh-CN.md b/llmdoc/architecture/decisions/0004-first-class-platform-packages.zh-CN.md new file mode 100644 index 0000000..dc35732 --- /dev/null +++ b/llmdoc/architecture/decisions/0004-first-class-platform-packages.zh-CN.md @@ -0,0 +1,46 @@ +# ADR-0004:Platform 是一等独立生态包 + +- 状态:已接受 +- 日期:2026-08-08 +- 适用范围:ACPlugin 1.0 package API + +## 背景 + +六个官方 Platform 原先是私有 `@acplugin/*` workspace 包,由主包内联并重新导出。该模型虽然让单 tarball 使用简单,却让官方 Platform 依赖第三方无法访问的 Core,并迫使主包知道全部官方实现。Extension 已证明“独立公开 package + 主包 peer dependency + 公开 lifecycle SDK”可以保持 owner、品牌和生命周期边界。 + +如果把 `@tokenroll/acplugin/platforms/` 做成 export subpath,它仍由主包拥有并统一版本化,不能提供独立安装、发布和第三方对等模型。 + +## 决策 + +1. `@tokenroll/acplugin` 只承担 CLI 和公开框架 SDK,不重新导出官方 Platform/Extension。 +2. 六个官方 Platform 分别发布为 `@tokenroll/acplugin-platform-`,两个 Extension 继续使用 `@tokenroll/acplugin-extension-`。 +3. 所有官方集成只从 `@tokenroll/acplugin/sdk` 导入公开契约,并把主包声明为 peer dependency;生产源码不得导入私有 Core。 +4. `platforms` 配置必填。主包不按缺省值或 ID 加载官方实现;`init` 通过显式依赖和 import 保留默认 Claude Code/Codex 的脚手架体验。 +5. 官方集成独立版本化,以 lifecycle `apiVersion` 和主包 peer range 表达兼容性。 +6. 第三方包无需注册、无需官方 scope,也不强制命名;只要使用公开工厂和契约即可参与同一 lifecycle。 +7. 1.0 不保留旧主包 re-export 或 Platform subpath 兼容层。 + +## 影响 + +- 使用者必须安装并 import 所需 Platform package,配置依赖变得显式、可审计。 +- 官方 Platform 成为第三方作者可复制的真实 package 范例。 +- 主包正常运行图不随官方 Platform 数量增长。 +- 发布验证从三个公开 tarball 扩展到九个,并验证 peer rewrite、品牌互操作与 clean consumer。 +- `init`、Migration、文档、fixture 和 release workflow 必须同步使用独立包名。 + +## 未采用方案 + +- 主包 `./platforms/*` subpath:仍由主包拥有版本和发布边界,不是一等生态包。 +- 同时保留 re-export:会让错误入口继续成为事实标准,并使主包无法真正收窄。 +- 按 Platform ID 自动安装或发现包:引入网络副作用、命名注册和不可重复解析。 +- 公开私有 Core 包:扩大内部 Registry/事务表面,破坏主包作为唯一 SDK 边界。 + +## 证据 + +- `packages/acplugin/src/index.ts` +- `packages/acplugin/src/author/project.ts` +- `packages/acplugin/src/sdk.ts` +- `packages/acplugin/tsdown.config.ts` +- `packages/platforms/*/package.json` +- `packages/extensions/*/package.json` +- 规范 §4.2–§4.4、§5.2、§19 diff --git a/llmdoc/architecture/system.md b/llmdoc/architecture/system.md index d3936e8..844f737 100644 --- a/llmdoc/architecture/system.md +++ b/llmdoc/architecture/system.md @@ -1,51 +1,102 @@ # System Architecture -## 1. Identity - -- **What it is:** A multi-stage pipeline: Source Resolution, Scanner, Converters, and Writers, with interactive TUI for plugin selection. -- **Purpose:** Transforms Claude Code plugin resources into platform-specific output files. - -## 2. Core Components - -- `src/index.ts` (`program`, `generateForPlatform`, `isGitHubSource`, `resolveSource`, `detectAndScan`): CLI entry point. Defines `scan` and `convert` commands. Auto-detects GitHub vs local source. Routes to marketplace, plugin, or project scan. Dispatches to platform-specific writers. -- `src/github.ts` (`parseGitHubSource`, `downloadGitHubRepo`, `cleanupTempDir`, `getTempRoot`): GitHub repo download without git clone. Parses `owner/repo`, `github:owner/repo#branch`, and full URLs. Downloads tarball via GitHub API, extracts to temp dir. Supports `GITHUB_TOKEN` env var for private repos. -- `src/tui.ts` (`selectPlugins`, `selectPlatforms`, `parseSelection`, `log`): Interactive checkbox selection via @inquirer/prompts. Falls back to select-all in non-TTY environments. Provides styled console output helpers via chalk. -- `src/types.ts` (`Skill`, `Instruction`, `MCPConfig`, `Agent`, `Command`, `Hooks`, `ScanResult`, `PluginMeta`, `PluginScanResult`, `ConvertResult`, `ConvertedFile`): Unified type definitions shared across all stages. `PluginMeta` includes `displayName`, `homepage`, `repository`, `license`, `keywords` optional fields for rich plugin metadata passthrough. -- `src/scanner/claude.ts` (`scanClaudeProject`): Scans a standard Claude Code project directory (`.claude/` layout). Returns a `ScanResult`. -- `src/scanner/plugin.ts` (`hasMarketplace`, `isSinglePlugin`, `scanMarketplace`, `scanPlugin`, `scanAllPlugins`, `countResources`): Scans Claude Code official plugin format. Handles `.claude-plugin/marketplace.json` (multi-plugin) and `.claude-plugin/plugin.json` (single plugin). Plugin resources live directly in plugin root (`skills/`, `agents/`, `commands/`, `hooks/`), not under `.claude/`. -- `src/converter/skill.ts`: Converts `Skill` objects to target platform format. -- `src/converter/instructions.ts`: Converts `Instruction` (CLAUDE.md, rules) to AGENTS.md or .mdc files. -- `src/converter/mcp.ts`: Converts `.mcp.json` servers to config.toml / opencode.json / .cursor/mcp.json. -- `src/converter/agent.ts` (`convertAgent`, `convertToCodex`, `convertToOpenCode`, `convertToCursor`, `convertToAntigravity`): Converts `.claude/agents/*.md` to platform-specific formats. Cursor outputs `.cursor/agents/*.md` with `name`, `description`, `model`, `readonly`. OpenCode outputs `.opencode/agents/*.md` with `mode: subagent`, `steps`, `permission` (edit/bash deny). Antigravity outputs `.agents/agents/*.md`; the Claude tool list is preserved as an HTML comment rather than mapped to an `allowed-tools` allowlist, since Antigravity's internal tool identifiers are unpublished. -- `src/converter/command.ts`: Converts `.claude/commands/*.md` to platform commands. -- `src/converter/hooks.ts` (`convertHooks`, `convertCursorHooks`, `CURSOR_EVENT_MAP`): Converts `settings.json` hooks with compatibility warnings for non-portable events. Cursor hooks use dedicated `convertCursorHooks()` path: maps PascalCase events to camelCase (`PostToolUse` → `postToolUse`), strips `${CLAUDE_PLUGIN_ROOT}` to relative paths, outputs `{ version: 1, hooks: {...} }` JSON at `hooks/hooks-cursor.json`. -- `src/writer/codex.ts` (`generateCodex`): Orchestrates all converters for Codex output. -- `src/writer/opencode.ts` (`generateOpenCode`): Orchestrates all converters for OpenCode output. -- `src/writer/cursor.ts` (`generateCursor`, `generatePluginJson`, `remapToPluginPath`): Orchestrates all converters for Cursor output. Generates `.cursor-plugin/plugin.json` manifest with auto-detected components from scan result. Plugin.json passes through `displayName`, `homepage`, `repository`, `license`, `keywords` from source `PluginMeta`. Includes `hooks` field pointing to `hooks/hooks-cursor.json` when hooks exist. Remaps all `.cursor/` output paths to plugin root layout (`skills/`, `agents/`, `commands/`, `rules/`, `mcp.json`). Targets the Cursor plugin/marketplace format introduced in Cursor 3.9 (2026-06). -- `src/writer/antigravity.ts` (`generateAntigravity`): Orchestrates all converters for Antigravity (Google) output. Skills → `.agents/skills/`, Instructions → `GEMINI.md`, MCP → `.agents/mcp_config.json`, Agents → `.agents/agents/*.md`, Commands → Skills. Uses the CLI workspace convention (plural `.agents/`), not the IDE `.agent/` convention. -- `src/writer/pi.ts` (`generatePi`): Orchestrates converters for Pi (pi-coding-agent) output. Skills → `.pi/skills/`, Instructions → `AGENTS.md`, Commands → `.pi/prompts/*.md`. MCP/agents/hooks have no Pi file format (Pi extends via TypeScript extensions), so the writer skips them and pushes warnings rather than calling those converters. -- `src/utils/model.ts` (`mapModel`, `CODEX_MODEL_MAP`, `ANTIGRAVITY_MODEL_MAP`): Maps Claude model names to platform equivalents. Codex → `gpt-5.6-sol` (haiku → `gpt-5.6-terra`), Antigravity → `gemini-3.1-pro-preview`/`gemini-3.6-flash`. OpenCode and Cursor pass models through unchanged. -- `src/utils/frontmatter.ts`: YAML frontmatter parse/stringify via gray-matter. -- `src/utils/toml.ts`: TOML serialization via @iarna/toml. -- `src/utils/fs.ts` (`writeFile`, `readFile`, `fileExists`): File system utilities with directory creation. -- `.github/workflows/acplugin.yml`: Repository CI workflow that runs `TokenRollAI/acplugin-action@v1` on Claude source changes in `main`. -- `.github/workflows/publish-npm.yml`: Release workflow. Triggers on `v*` tags, verifies the tag matches `package.json` version, runs install/build/test/package validation, then publishes to npm with GitHub Actions OIDC Trusted Publishing. - -## 3. Execution Flow (LLM Retrieval Map) - -- **1. CLI Parse:** User invokes `acplugin scan [source]` or `acplugin convert [source]`. Commander.js parses args in `src/index.ts:16-21`. Source is a positional argument defaulting to `.`. -- **2. Source Resolution:** `isGitHubSource()` at `src/index.ts:26-36` auto-detects GitHub sources. `resolveSource()` at `src/index.ts:41-53` either downloads via `src/github.ts:74-109` or resolves a local path. Cleanup callback is returned for temp dirs. -- **3. Detection & Scan:** `detectAndScan()` at `src/index.ts:58-69` checks for marketplace (`hasMarketplace`), single plugin (`isSinglePlugin`), or standard project, then calls the appropriate scanner. -- **4. Interactive Selection (convert only):** If `--to` not specified, `selectPlatforms()` from `src/tui.ts:39-56` prompts for platform selection. For marketplace repos without `--all`, `selectPlugins()` from `src/tui.ts:10-34` prompts for plugin selection. -- **5. Convert:** Each writer (e.g., `src/writer/codex.ts`) calls converter modules (`src/converter/*.ts`) for each resource type, collecting `ConvertedFile[]` and warnings. -- **6. Write:** `convertSingleScan()` at `src/index.ts:193-227` iterates over `ConvertedFile[]` and writes each to disk via `src/utils/fs.ts`, unless `--dry-run` is set. -- **7. Report:** `printConvertReport()` at `src/index.ts:288-303` outputs a summary of generated files and warnings. - -## 4. Design Rationale - -- **One-way conversion only:** Claude Code is the source of truth. Bidirectional sync would create conflict resolution complexity with no clear benefit. -- **HTML comment preservation:** Claude-specific frontmatter fields (e.g., `allowed-tools`, `effort`) are embedded as HTML comments in output so they are not lost but do not break target platforms. -- **Native subagent support:** Codex, OpenCode, Cursor, and Antigravity support agents natively; each writer generates platform-specific agent frontmatter (Cursor: `readonly`; OpenCode: `mode`, `steps`, `permission`; Antigravity: Claude tool list preserved as a comment). Pi has no subagent format, so its writer emits a warning. -- **Model mapping:** `src/utils/model.ts` centralizes Claude-to-platform model translation, defaulting to the platform's strongest model when no mapping exists. -- **Auto-detection over flags:** Source type (GitHub/local) and format (marketplace/plugin/project) are auto-detected to minimize required CLI arguments. -- **Non-TTY fallback:** TUI selection defaults to "all" when stdin is not a TTY, enabling CI/script usage without interactive prompts. +> [中文对照](system.zh-CN.md) + +## Product boundary + +ACPlugin is a Rolldown-powered AI Plugin framework and CLI. It combines project scaffolding with a build system that remains in the project for validation, development, packaging, compatibility reporting, and managed output updates. + +The public package boundary is deliberately split: + +- `@tokenroll/acplugin` is the author facade, CLI, Project API, report API, init, and isolated Migration entry. +- Six `@tokenroll/acplugin-platform-*` packages own target-specific Package formats. +- `@tokenroll/acplugin-extension-hooks` and `@tokenroll/acplugin-extension-mcp` own optional horizontal authoring formats. +- `@acplugin/core` is private and is bundled into the main package. + +Configuration authors import from `@tokenroll/acplugin`. Trusted Platform and Extension implementations import contracts from `@tokenroll/acplugin/sdk`. The main package never bundles, discovers, or re-exports official integrations. + +Configuration, descriptor, Platform, and Extension modules execute as trusted build-time code in the host Node.js process; they are not process sandboxes. Core service capabilities govern which sources and outputs can enter managed Packages and reports, not what a malicious integration could read through Node.js itself. Factory results carry a `Symbol.for(...)` shared registry brand so root, SDK, and CLI bundle chunks recognize the lifecycle definition. This brand is interoperable identity metadata, not a private Symbol, capability token, or security boundary. + +## Fixed lifecycle + +```text +config load/resolve +→ Platform and Extension Session setup +→ canonical/Public/Runtime/Extension resource discovery +→ immutable CanonicalProject assembly +→ Platform Component and Extension validation +→ Extension and Core Runtime compilation +→ Platform.createPackage +→ Framework and Extension Contributor collection +→ Core add-only merge +→ Platform.finalizePackage +→ primary candidate materialization and validation +→ optional Distribution creation and validation +→ compatibility and metadata finalization +→ aggregate materialization validation +→ managed output transaction +→ reverse Session close +``` + +Core is the only scheduler. Platform Package pipelines are isolated from one another, while stable registries make diagnostics and reports independent of concurrent completion order. Every initialized Session is closed exactly once in reverse order after success or failure; close receives only a sanitized outcome summary. + +## Core service ownership + +Core owns the physical filesystem and process capabilities: + +- `SourceRegistry` issues owner-scoped `SourceFileRef` and `SourceDirectoryRef` values after path, type, symlink, case, and Unicode checks. +- `ModuleHost` evaluates trusted TypeScript/JavaScript config and descriptors and registers their module graphs for dev. +- `CompilerHost` is the only Rolldown owner. `portable-node` provides the framework contract for Hooks, local MCP, and built-in Runtime; `managed-rolldown` exposes a bounded Rolldown surface to integrations. +- `ExecutionHost` runs only current-session generated Node Assets with bounded input, output, timeout, cwd, and environment. +- `AssetRegistry` signs Source, Generated, and Bytes Assets, enforces grants, and records mode, hash, size, owner, and structured origin. +- `WatchRegistry`, the Package candidate materializer, compatibility registry, and output transaction remain Core-only. + +Platforms and Extensions never receive a physical work directory or direct `dist` access through the framework contract. They express managed output through Core-issued references and owner-scoped services; this output boundary does not turn trusted Integration code into a process sandbox. + +## Resources and Project + +The framework-owned resource model contains: + +- Commands from `src/commands/.md`; +- Skills from `src/skills//SKILL.md` plus exact auxiliary files; +- Agents from `src/agents/.md`; +- Public files from `public` or explicit copy rules; +- built-in Node Runtime entries from direct `src/runtime` TS/JS files or explicit `runtime.entries`. + +Hooks and MCP are Extension-owned roots. A root containing author files without its owning Extension is a configuration error. Instructions are intentionally not a canonical Component. + +The graph assembler freezes one `CanonicalProject`. Component dependency validation rejects missing, self, and cyclic dependencies before Package creation. Runtime is compiled once by Core only when a selected Platform declares the exact Plugin-local Node 20 ESM capability. + +## Platform Packages and Contributions + +A Platform Session owns: + +1. optional Component field validation; +2. `createPackage()` for base Documents, Assets, compatibility, and metadata dispositions; +3. `finalizePackage()` for primary Package identity and optional additional Platform Assets; +4. `validatePackage()` against the fully materialized candidate; +5. optional `createDistributions()` from an already validated primary Package. + +An Extension validates and builds one platform-neutral state. Its `PlatformContributor` instances all read the same immutable Platform base Package and return independent `PackageContribution` values. A Contribution may add fields only at declared empty Document extension points, add Assets owned by that Extension, report compatibility, or submit subject-bound opaque JSON Platform Components. Core transports those payloads deterministically; only the receiving Platform finalizer validates, renders, names, and optionally registers its native resource. A Contributor cannot read another Extension state, observe another Contribution, replace a Document, delete output, or claim a Canonical Component. + +Core validates all Contributions, then performs one deterministic add-only merge. Conflicting Document fields or Package paths fail regardless of Extension configuration order. + +## Output, reports, and dev + +Package candidates are materialized only under Core-owned temporary roots. Platform validation therefore sees the exact file tree that would be installed. Distribution Assets inherit the validated primary Asset identity unless the Platform explicitly adds a newly signed Asset. + +`BuildReport` schema version 3 contains Components, Runtimes, Extensions, Platform status, Packages, Asset provenance, compatibility, metadata dispositions, and stage-bound diagnostics. Component-driven generated Assets and finalization Documents may record stable contributor owner/subject provenance. It contains no bytes, timestamps, environment values, project absolute paths, or temporary roots. + +The managed output transaction treats the selected Platform set as one replacement: + +```text +lock → recover → stage → validate → backup → swap → cleanup +``` + +Any failure keeps the last complete output. `DevSession` remains Core-owned: it maintains one active build round, coalesces pending changes, reconciles the latest module/source graph, keeps the last successful output after failure, and drains safely on close or process signals. + +The fixed transaction lock record is published complete with a no-replace hard link. Short-lived lock-metadata operations are serialized by unique PID/token guard intents, so stale recovery cannot rename a live replacement observed after an earlier read; dead guard paths are exact, never-reused identities. Stale recovery also compares inode/content metadata and bytes. This schema-3 protocol does not claim concurrent lock interoperability with pre-schema-3 beta processes. + +## Migration isolation + +Migration is dynamically imported from `packages/acplugin/src/migration/`. Its tolerant legacy readers operate only on untrusted migration input and do not form a second normal build path. Content that cannot be mapped safely is written to `.acplugin-migration/unmapped/` with a stable report; it is never fabricated into canonical Hooks, local MCP implementations, or Instructions. diff --git a/llmdoc/architecture/system.zh-CN.md b/llmdoc/architecture/system.zh-CN.md new file mode 100644 index 0000000..90ecc29 --- /dev/null +++ b/llmdoc/architecture/system.zh-CN.md @@ -0,0 +1,102 @@ +# 系统架构 + +> [English](system.md) + +## 产品边界 + +ACPlugin 是基于 Rolldown 的 AI Plugin 框架和 CLI。它既负责初始化工程,也作为持续使用的构建系统留在项目中,统一完成校验、开发监听、打包、兼容性报告和托管输出更新。 + +公开 package 刻意分层: + +- `@tokenroll/acplugin` 是作者 facade、CLI、Project API、报告 API、init 和隔离 Migration 入口; +- 六个 `@tokenroll/acplugin-platform-*` package 拥有目标平台 Package 格式; +- `@tokenroll/acplugin-extension-hooks` 与 `@tokenroll/acplugin-extension-mcp` 拥有可选横向作者格式; +- `@acplugin/core` 保持私有,并由主包 bundle。 + +配置作者从 `@tokenroll/acplugin` 导入。可信 Platform/Extension 实现从 `@tokenroll/acplugin/sdk` 导入契约。主包不会 bundle、发现或重新导出官方集成。 + +配置、descriptor、Platform 与 Extension 模块都是在宿主 Node.js 进程中执行的可信构建时代码,不是进程沙箱。Core Service capability 约束哪些来源与输出可以进入受管 Package 和报告,并不阻止恶意 Integration 自行通过 Node.js 读取内容。Factory result 携带 `Symbol.for(...)` 共享 registry brand,使主包 root、SDK 与 CLI bundle chunk 能识别生命周期定义;它只是可互操作的身份元数据,不是 private Symbol、权限令牌或安全边界。 + +## 固定生命周期 + +```text +config load/resolve +→ Platform 与 Extension Session setup +→ canonical/Public/Runtime/Extension Resource discover +→ immutable CanonicalProject assembly +→ Platform Component 与 Extension validate +→ Extension 与 Core Runtime compile +→ Platform.createPackage +→ Framework/Extension Contributor collection +→ Core add-only merge +→ Platform.finalizePackage +→ primary candidate materialize/validate +→ optional Distribution create/validate +→ compatibility 与 metadata finalize +→ aggregate materialization validate +→ managed output transaction +→ reverse Session close +``` + +Core 是唯一调度者。各 Platform 的 Package pipeline 相互隔离;稳定 Registry 使诊断和报告不依赖并发完成顺序。所有已初始化 Session 在成功或失败后都恰好逆序关闭一次,`close` 只收到脱敏结果摘要。 + +## Core Service 所有权 + +Core 独占物理文件系统和进程能力: + +- `SourceRegistry` 在路径、类型、symlink、大小写和 Unicode 校验后签发 owner-scoped `SourceFileRef`/`SourceDirectoryRef`; +- `ModuleHost` 执行可信 TypeScript/JavaScript 配置与 descriptor,并把模块图登记到 dev; +- `CompilerHost` 是唯一 Rolldown owner。`portable-node` 为 Hooks、本地 MCP 和内建 Runtime 提供框架契约,`managed-rolldown` 向集成暴露受限 Rolldown 能力; +- `ExecutionHost` 只运行当前 Session 生成的 Node Asset,并限制输入、输出、超时、cwd 和环境; +- `AssetRegistry` 签发 Source、Generated、Bytes Asset,执行授权,并记录 mode、hash、size、owner 和结构化 origin; +- `WatchRegistry`、Package candidate materializer、compatibility registry 和输出事务保持 Core-only。 + +Platform/Extension 不通过框架契约获得物理 workDir 或 `dist` 写权限,只能通过 Core 引用和 owner-scoped Service 表达受管输出;这一输出边界不会把可信 Integration 代码变成进程沙箱。 + +## Resource 与 Project + +Framework-owned Resource 包含: + +- `src/commands/.md` 中的 Command; +- `src/skills//SKILL.md` 及精确辅助文件; +- `src/agents/.md` 中的 Agent; +- `public` 或显式 copy 规则中的 Public 文件; +- `src/runtime` 一级 TS/JS 文件或显式 `runtime.entries` 定义的内建 Node Runtime 入口。 + +Hooks 与 MCP 是 Extension-owned root。root 中存在作者文件但没有启用 owner Extension 时属于配置错误。Instructions 被明确排除在 canonical Component 之外。 + +Graph assembler 冻结唯一 `CanonicalProject`。Component 依赖在 Package 创建前拒绝缺失、自依赖和循环。只有选中 Platform 声明精确 Plugin-local Node 20 ESM capability 时,Core 才把 Runtime 编译一次。 + +## Platform Package 与 Contribution + +Platform Session 拥有: + +1. 可选 Component 字段校验; +2. 通过 `createPackage()` 创建 base Document、Asset、compatibility 与 metadata disposition; +3. 通过 `finalizePackage()` 确定 primary Package 身份和可选新增 Platform Asset; +4. 通过 `validatePackage()` 校验完整物化候选; +5. 可选地从已验证 primary Package 创建 Distribution。 + +Extension 校验并构建一次平台中立 State。它的 `PlatformContributor` 都读取同一份不可变 Platform base Package,并独立返回 `PackageContribution`。Contribution 可以向声明且为空的 Document extension point 增加字段、增加本 Extension 自有 Asset、报告兼容性,或提交 subject-bound 的不透明 JSON Platform Component。Core 只确定性传输 payload;只有接收 Platform 的 finalizer 负责校验、渲染、命名并按需注册原生资源。Contributor 不能读取其他 Extension State、观察其他 Contribution、替换 Document、删除输出或接管 Canonical Component。 + +Core 先验证全部 Contribution,再执行一次确定性 add-only merge。Document 字段或 Package 路径冲突与 Extension 配置顺序无关,始终失败。 + +## 输出、报告与 dev + +Package candidate 只在 Core 临时根中物化,因此 Platform 校验看到的就是最终将安装的文件树。Distribution 继承已验证 primary Asset 的身份;只有 Platform 显式签发新 Asset 时才能增加内容。 + +Schema version 3 `BuildReport` 包含 Component、Runtime、Extension、Platform 状态、Package、Asset provenance、兼容性、metadata disposition 和精确阶段诊断。由 Component contribution 决定的生成 Asset 和 finalization Document 可以记录稳定的 contributor owner/subject provenance;报告不包含字节、时间戳、环境值、工程绝对路径或临时根。 + +托管输出事务把选中 Platform 集合作为一次整体替换: + +```text +lock → recover → stage → validate → backup → swap → cleanup +``` + +任一失败都保留上一份完整输出。`DevSession` 由 Core 独占:同时只有一个 active round,快速变化合并为 pending,最新模块/来源图会被重新协调;失败保留最后成功输出,关闭或进程信号会安全 drain。 + +固定 transaction lock record 通过 no-replace hard link 完整发布。短生命周期的 lock metadata 操作由唯一 PID/token guard intent 串行化,因此 stale recovery 不会 rename 首次读取后出现的活跃 replacement;dead guard 使用永不复用的精确 identity 回收。stale recovery 还会同时比较 inode/content metadata 与字节。本 schema-3 协议不承诺和 pre-schema-3 beta 进程并发构建时的 lock 互操作。 + +## Migration 隔离 + +Migration 从 `packages/acplugin/src/migration/` 动态导入。容错 legacy reader 只处理不可信迁移输入,不形成第二条正常构建路径。无法安全映射的内容写入 `.acplugin-migration/unmapped/` 和稳定报告,不会伪装成 canonical Hook、本地 MCP 实现或 Instructions。 diff --git a/llmdoc/guides/package-code-tour.zh-CN.md b/llmdoc/guides/package-code-tour.zh-CN.md new file mode 100644 index 0000000..6998c31 --- /dev/null +++ b/llmdoc/guides/package-code-tour.zh-CN.md @@ -0,0 +1,284 @@ +# ACPlugin Kernel v2 代码导览 + +本文只描述当前 Kernel v2。旧 `Draft/Adapter/DeliveryUnit/Artifact/BuildResult` 生命周期已经删除,不是兼容路径。 + +## 1. Workspace 与公开边界 + +| 目录 | Package | 可见性 | 责任 | +| --- | --- | --- | --- | +| `packages/acplugin` | `@tokenroll/acplugin` | 公开 | CLI、作者 façade、`/sdk`、Project API、init、隔离 Migration;bundle Core | +| `packages/core` | `@acplugin/core` | 私有 | Kernel、Resource/Host/Registry、Compiler、Package、事务、报告 | +| `packages/platforms/*` | 六个 `@tokenroll/acplugin-platform-*` | 公开 | 单一目标平台转换、Package、Distribution 与 candidate validator | +| `packages/extensions/hooks` | `@tokenroll/acplugin-extension-hooks` | 公开 | Hook 作者协议、单次构建、安全 runner、六平台 Contributor | +| `packages/extensions/mcp` | `@tokenroll/acplugin-extension-mcp` | 公开 | HTTP/stdio MCP 协议、stdio 构建/smoke、六平台 Contributor | +| `packages/test` | `@acplugin/test` | 私有 | 跨包、CLI、Migration、tarball 与架构测试 | +| `packages/docs` | `@acplugin/docs` | 私有 | VitePress 与九个公开 package 的 TypeDoc | +| `packages/playground` | `@acplugin/playground` | 私有 | 领域中立的真实全能力消费模板 | + +公开调用面有意分成两层: + +- `@tokenroll/acplugin`:普通作者使用 `defineConfig()`,程序化调用方使用 `createProject()`、`runProject()`、`Project.dev()`,并读取 schema-v3 `BuildReport`。 +- `@tokenroll/acplugin/sdk`:Platform/Extension 实现使用 `definePlatform()`、`defineExtension()`、Session/Contributor/Compiler/Asset 契约与稳定序列化工具。 + +官方 Platform/Extension 以主包为 peer,只能 import `/sdk`,不能 import `@acplugin/core`。主包不 bundle 或重导出任何官方集成。 + +## 2. 固定执行图 + +```text +acplugin.config.ts +→ config load/resolve +→ Platform/Extension Session setup +→ canonical/Public/Runtime/Extension discovery +→ CanonicalProject assembly +→ Component/Extension validation +→ Extension/Core Runtime compilation +→ Platform.createPackage +→ Framework/Extension Contributor collection +→ Core add-only merge +→ Platform.finalizePackage +→ primary candidate materialize/validate +→ Distribution create/validate +→ compatibility/metadata finalization +→ aggregate materialization validation +→ managed transaction +→ reverse Session close +→ BuildReport schema v3 +``` + +CLI、`runProject()`、`Project.run()` 与 `Project.dev()` 的每个重建轮次最终都进入 `packages/core/src/lifecycle/build-session.ts`。不存在 CLI 专用构建器或 Extension 自己的 pipeline。 + +## 3. 主包入口 + +| 文件 | 作用 | +| --- | --- | +| `packages/acplugin/src/index.ts` | 作者/程序化根入口;只导出配置、Project、报告和 Runtime path helper | +| `packages/acplugin/src/sdk.ts` | Integration 唯一实现入口,转出 Core `api/integration` | +| `packages/acplugin/src/author/project.ts` | 把公开 Project API 绑定到 Core lifecycle 与当前 Framework version | +| `packages/acplugin/src/cli.ts`、`src/cli/` | 薄入口与 init、validate、inspect、build、dev、migrate 命令;只消费 Project API | +| `packages/acplugin/src/scaffolding/` | 显式 Platform/Extension 脚手架与内建 Runtime 模板 | +| `packages/acplugin/src/ecosystem/` | init、Migration 与 release verifier 共用的公开生态版本快照 | +| `packages/acplugin/src/migration/` | 动态 import 的隔离迁移子系统 | + +`defineConfig()` 是唯一作者 define factory,仅用于类型推断。Hook、MCP descriptor 与 Runtime 源码使用 plain default export / 目录约定,不需要 `defineHook()`、`defineMcpServer()`、`defineNodeRuntime()` 或 `nodeRuntime()`。 + +## 4. Core 契约层 + +| 文件 | 作用 | +| --- | --- | +| `contracts/` | 按 common、config、component、project、integration、compiler、package、service、report 拆分的契约类型 | +| `api/definitions.ts` | `definePlatform()` / `defineExtension()` 精确字段验证、严格 JSON snapshot、品牌与冻结 | +| `api/author.ts` | 主包根入口允许公开的作者/报告类型 | +| `api/integration.ts` | `/sdk` 允许公开的 Integration 类型与工具 | +| `serialization/` | 确定性 JSON/YAML/frontmatter 与 Document 序列化 | + +`LIFECYCLE_API_VERSION` 保持 `'1'`。该值表达当前 Session/Contributor 契约版本,不表示保留被删除的旧 shape。 + +Platform options 和 Extension options 必须是深度冻结的严格 JSON。定义对象拒绝未知字段、accessor、Symbol、稀疏数组、自定义 prototype、循环与非有限数字,防止 setup 后继续观察调用方 mutation。 + +## 5. Capability Registry 与 Host + +Kernel 不把物理路径和任意文件系统权限交给 Integration,而是签发与当前 BuildSession/owner 身份绑定的 capability: + +| 模块 | 能力 | +| --- | --- | +| `services/sources.ts` | 验证来源根、普通文件、symlink/特殊文件、SourceRef 授权 | +| `compiler/module-host.ts` | 通过唯一受管 Rolldown ESM 图加载可信 TypeScript/JavaScript config/descriptor | +| `compiler/compiler-service.ts` | 当前 Session 唯一 Rolldown owner,返回 GeneratedAssetRef 与脱敏模块图 | +| `services/execution.ts` | 在隔离 cwd、最小显式环境、超时和输出上限内执行 portable Node Asset | +| `services/assets.ts` | 签发 Source/Generated/Bytes AssetRef,记录 owner/origin/mode/size/hash 与 grant | +| `services/watch.ts` | 集中记录 Resource、Module、Compiler 实际读取的依赖 | +| `services/work-directories.ts` | 为 owner 管理不可伪造的内部 workDir;不公开物理写权限 | +| `services/session-scope.ts` | Session 结束后统一撤销所有 capability identity | + +AssetRef 不是可伪造的 `{ path }`。Registry 使用对象身份验证当前 Session、真实 issuer 与 consumer grant;报告中的 origin 为结构化工程相对来源,不影响内容 hash。 + +## 6. Resource Provider + +`packages/core/src/resources/` 把作者布局转换为唯一 `CanonicalProject`: + +- `canonical/provider.ts`:Command、Skill、Agent Markdown/frontmatter、辅助资源与平台字段。 +- `project-graph.ts`:拒绝缺失、自依赖和循环依赖。 +- `public.ts`:默认 `public/` 和显式 copy mapping。 +- `runtime/provider.ts`:内建 Node Runtime 自动/显式入口、编译与 capability-driven Contribution。 +- `extensions.ts`:Extension discover/validate/build state snapshot、consumer plan 与 Contributor 收集。 +- `registry.ts`:声明/认领资源根,拒绝未启用 Extension 遗留目录和根冲突。 + +所有 ID、目录项和报告集合按 code point 稳定排序;路径统一拒绝绝对路径、NUL、`..`、大小写/Unicode normalization 冲突与文件/目录前缀冲突。 + +## 7. 统一 Rolldown Compiler + +`context.compiler.compile()` 提供两种 profile: + +### `portable-node` + +用于内建 Runtime、Hooks 和 local MCP。它固定 Node 20 ESM、自包含 bundle、只 externalize `node:` builtin、无 sourcemap,并拒绝未解析 import、原生扩展、隐式运行时依赖和不确定输出。许可证默认严格收集,实际包含第三方 package 时产生 `THIRD_PARTY_LICENSES.txt`。 + +作者只可调整 `PortableNodeCompileOptions` 的 JSON-safe `resolve`、`transform` 和 `treeshake` 子集,不能注入 plugin 或回调。 + +### `managed-rolldown` + +供第三方 Integration 使用 Rolldown 的受管能力。Core 始终接管 cwd、input identity、输出、日志、watch、close、workDir 和 Asset 签发,禁止 `writeBundle`、`watchChange`、`closeWatcher` 等越权 Hook。许可证默认 `strict`;显式 `ignore` 仅表示调用方承担法律材料责任,不关闭其余安全审计。 + +Module graph 的实际 source、package、tsconfig 与 license 输入全部进入 Watch Registry。Platform/Extension 不得直接依赖 Rolldown并建立第二套 bundler。 + +## 8. Package、Document 与 Contribution + +Platform `createPackage()` 返回: + +- 结构化 Document(JSON/YAML/TOML/frontmatter)及明确的空 extension point; +- package-relative Asset mappings; +- Canonical Component 兼容性; +- metadata emitted/omitted 结论。 + +Core 把它复制为冻结的 `PlatformBasePackageSnapshot`。Framework Runtime/Public 与所有 Extension Contributor 都读取这一份相同 snapshot;Extension Contribution 并发收集,按 owner 稳定排序后集中合并。 + +Contribution 只能: + +- 填写已声明、当前为空的 Document field path; +- 追加当前 owner 已签发或获 grant 的 Asset; +- 提交与 Extension 已声明 subject 绑定、仅由目标 Platform 解释的不透明 JSON Component payload; +- 精确覆盖 Extension validate 阶段声明的 compatibility tuple。 + +payload 不是 Canonical Component,Core 不读取其业务字段。只有 `finalizePackage()` 看到 merged payload;目标 Platform 负责 schema、命名空间、输出路径、渲染与 candidate validation。Claude Code、Cursor、OpenCode 首期各自支持原生 Agent payload;Codex、Antigravity、Pi 对非空 payload 稳定失败,绝不静默忽略或生成 fallback。 + +它不能读取其他 Contribution、替换/删除已有字段、append 任意数组、claim/suppress Canonical Component 或覆盖 Asset 路径。同一字段、路径或 tuple 竞争稳定失败,不使用 `extensions[]` 顺序解决。 + +`finalizePackage()` 读取 merged snapshot,只决定主 Package identity/type 并可追加 Platform 自有 Asset。Core 自动继承全部 base/contribution 内容。主 Package 通过完整临时候选校验后,Platform 才能创建 Marketplace Distribution;Distribution 也必须保持继承 Asset 完整性并再次校验。 + +相关实现位于: + +- `package/registry.ts` +- `package/documents.ts` +- `package/candidate-materializer.ts` +- `package/distributions.ts` +- `package/compatibility.ts` +- `package/report-builder.ts` + +## 9. Platform package + +六个官方 Platform 都遵循同一结构: + +```text +src/ +├── index.ts # factory、definePlatform、Session +├── types.ts # 可选,公开 options 与目标格式类型 +└── package/ + ├── components.ts # Canonical Component 转换与 compatibility + ├── manifest.ts # 或 config-document.ts;结构化 Platform Document/metadata + ├── protocol.ts # 可选,目标协议的共享常量与 wire helper + └── validator.ts # 小平台使用;Claude Code/Codex 按 validation/* 协议域拆分 +``` + +Platform 只声明能力,不要求 Core 按 ID 分支。当前 Claude Code/Codex 声明固定 Plugin-local Node 20 ESM capability;Runtime Provider 据此交付相同 AssetRef。其他 Platform 得到 `unsupported` 且无伪 Runtime。 + +主交付形态:Claude Code/Codex/Cursor/Antigravity 为 Plugin,OpenCode 为 Workspace,Pi 为 npm Package。Claude Code/Codex 可派生 Marketplace Distribution。 + +## 10. Extension package + +Hooks 与 MCP 都采用: + +```text +types.ts → discovery.ts → build.ts → contributors/.ts +``` + +Hooks 交付进 bundle 的 runner、integration 与 wire 源码集中在 `runtime/`;MCP 没有无实现意义的空 Runtime 层级。两者都通过公开 SDK 的唯一 strict JSON snapshot 建立 descriptor 数据边界。 + +### Hooks + +- descriptor:`src/hooks//hook.ts` plain default export; +- build:每个 handler 经 Core `portable-node` 只 bundle 一次; +- runtime:编译进 handler 的 wire profile 拥有平台 stdin/stdout、camelCase、root/data 映射和稳定错误码; +- Contributors:为六个平台追加受支持 runtime/config,并逐事件/字段报告兼容性。 + +### MCP + +- descriptor:`src/mcp//mcp.ts` plain HTTP/stdio 判别联合; +- HTTP:只交付 URL、header 与 `{ env }` Secret 引用,构建不读取值; +- stdio:完整 server 通过 Core `portable-node` 构建,并以真实 `initialize → initialized → tools/list` smoke 校验; +- Contributors:Claude Code/Codex/OpenCode 可消费 local stdio,Pi 全部 unsupported,其他平台按真实 transport 能力报告。 + +Extension 没有自己的 Rolldown、watcher、输出事务、依赖图或顺序 API。 + +## 11. 内建 Node Runtime + +Runtime 不使用 descriptor 或 Extension factory: + +- 默认把 `src/runtime/` 一级 TS/JS 文件当作 executable entry; +- `runtime.entries` 完整替换自动发现,可声明嵌套文件与 `module` kind; +- Core 使用一个逻辑 `portable-node` Job 编译全部入口,每个入口产生独立 `main.mjs` 和可选许可证; +- 固定输出路径为 `runtime//main.mjs` 与 `runtime//THIRD_PARTY_LICENSES.txt`; +- supported Platform 继承相同 Asset bytes,unsupported Platform 只报告兼容性。 + +语义 TypeScript 类型检查仍由作者工程 `tsc --noEmit` 负责;Runtime 编译只负责模块转换、bundle、交付、安全与许可证。 + +## 12. DevSession 与事务 + +`lifecycle/dev-session.ts` 是唯一 Chokidar owner: + +- 同一时间只有一个 active BuildSession; +- active round 期间的变化合并到下一轮; +- 每轮根据 Resource/Module/Compiler 实际快照动态 reconciliation watcher; +- watcher ready 后补偿构建关闭初始扫描竞态; +- config 失败后仍保留必要恢复监听; +- rebuild 失败保留最后一次成功输出; +- signal drain 与 `close()` 幂等。 + +`output/transaction.ts` 对所选目标集合执行: + +```text +lock → recovery → stage → materialization validation +→ transaction record/backup → swap → Session close → cleanup +``` + +commit 的 Session close 位于 swap 后仍可 rollback 的窗口。任何必要 close 失败都会恢复旧输出。subset 构建在锁内验证并保留未选 Platform;fault-injection 测试覆盖每个持久化边界。 + +## 13. BuildReport + +schema-v3 `BuildReport` 包含: + +- framework/compiler version、command、mode、success、committed; +- Component、Runtime、Extension、Platform 状态; +- Package Unit 与 Asset 的 path/owner/origin/mode/size/SHA-256; +- Component contribution 驱动的生成 Asset/finalization Document 的稳定 contributor owner/subject provenance; +- compatibility 与 metadata disposition; +- 绑定 phase/owner/platform/extension/component 的稳定诊断。 + +报告不包含 Asset bytes、原始异常、Secret 值、环境值、工程绝对路径、临时路径或时间戳。CLI `--json` 与程序化 API 返回同一结构。 + +## 14. 测试与质量门 + +| 目录/脚本 | 重点 | +| --- | --- | +| `packages/core/test/` | Resource/graph、Host、Compiler、Registry、Package、transaction、DevSession | +| `packages/platforms/*/test/` | Component 转换、golden、candidate validator、确定性 | +| `packages/extensions/*/test/` | descriptor、build、Contributor、compatibility、真实 runtime/protocol | +| `packages/test/test/{architecture,api,cli,platforms,extensions,release}/` | 架构、公开 API、CLI、六平台、Extension 与发行边界集成;Migration 保持根测试路径 | +| `scripts/verify-playground.mjs` | 全能力文件树、协议执行、Secret、双构建确定性 | +| `.github/workflows/changelog.yml` | `main` 合并后消费 Changeset 并维护版本/CHANGELOG PR | +| `.github/workflows/release.yml` | 手工发布稳定 npm 版本;beta 使用本地 pnpm 命令 | + +完整门禁: + +```bash +pnpm run lint +pnpm run typecheck +pnpm run test +pnpm run build +pnpm run docs:check +``` + +## 15. 修改入口速查 + +| 变更 | 首要位置 | 必须联动 | +| --- | --- | --- | +| Canonical Component 字段/布局 | `resources/canonical/`、`contracts/components.ts` | 六 Platform 转换、graph、报告、golden | +| Package/Asset 安全不变量 | `services/assets.ts`、`package/*` | owner、candidate、transaction、report 测试 | +| Compiler profile | `compiler/*`、`contracts/compiler.ts` | Watch、license、Hooks/MCP/Runtime、SDK type tests | +| Platform 格式 | 对应 `packages/platforms/` | compatibility、validator、Extension Contributor | +| Extension 作者协议 | 对应 `types.ts`/`discovery.ts` | build、六 Contributor、protocol smoke | +| Runtime 约定 | `resources/runtime/`、`config/resolver.ts` | capability、路径 helper、双平台交付与执行 | +| dev 监听 | `lifecycle/dev-session.ts`、各 Service watch observation | CLI 子进程、恢复、coalescing、close 测试 | +| 输出事务 | `output/transaction.ts` | fault injection、full/subset、rollback、cleanup | +| 公开 API | `api/author.ts` 或 `api/integration.ts`、主包入口 | TypeDoc、type tests、tarball consumer、peer range | + +推荐阅读顺序:`acplugin/src/index.ts` 与 `sdk.ts` → `core/src/contracts/` → `lifecycle/build-session.ts` → Resource/Package Registry → 一个官方 Platform → Hooks/MCP → DevSession 与 output transaction → Playground/release verifier。 diff --git a/llmdoc/guides/release.md b/llmdoc/guides/release.md index b5aff10..4d1b1e5 100644 --- a/llmdoc/guides/release.md +++ b/llmdoc/guides/release.md @@ -1,15 +1,49 @@ -# How to Publish `@disdjj/acplugin` to npm +# Releasing independently versioned public packages -Release publishing is automated by GitHub Actions. The workflow lives at `.github/workflows/publish-npm.yml` and publishes only from Git tags that match the package version. +> [中文对照](release.zh-CN.md) -1. Update the package version in `package.json` and `package-lock.json`. +ACPlugin has nine independently versioned public npm packages: the main package, six Platform packages, and Hooks/MCP Extensions. Core, Test, Docs, and Playground are private and must never be published. -2. Commit the version bump to `main`. +Repository tooling requires Node.js `^22.18.0 || >=24.11.0`. Published packages separately support `^20.19.0 || ^22.13.0 || >=23.5.0`. -3. Create and push a Git tag in the form `vX.Y.Z`. The tag must exactly match `package.json` version. Example: package version `1.5.3` requires tag `v1.5.3`. +## Workflow -4. GitHub Actions runs `.github/workflows/publish-npm.yml` on the tag push. The workflow validates the tag-version match, then runs `npm ci`, `npm run build`, `npm test`, `npm pack --dry-run`, and `npm publish`. +1. A feature pull request targeting `main` includes a Changeset for every affected public package. +2. `Lint` and `Typecheck` Actions run independently when that pull request is created or updated. +3. After the feature merges into `main`, `Changelog` consumes pending Changesets and creates or updates `chore(release): version packages`. The version PR contains package manifest versions, changelogs, and the generated public-version snapshot. It does not publish. +4. Merge the version PR only after reviewing the intended independent version bumps. +5. Publish a beta locally, or manually dispatch `Release` for stable versions. -5. npm authentication uses Trusted Publishing, not a long-lived token. The npm package `@disdjj/acplugin` must be configured to trust the GitHub repository `TokenRollAI/acplugin` and workflow `.github/workflows/publish-npm.yml`. +The repository setting **Actions → General → Workflow permissions → Allow GitHub Actions to create and approve pull requests** must be enabled for `Changelog` to create its version PR with `GITHUB_TOKEN`. -6. Package metadata required for publishing is stored in `package.json`. `repository.url` must point to `https://github.com/TokenRollAI/acplugin.git`, and `publishConfig.access` must stay `public` because the package is scoped. +## Prepare a beta + +From the merged version revision, inspect pnpm's no-write plan: + +```bash +pnpm install --frozen-lockfile +pnpm run publish:beta:dry-run +``` + +When the plan is correct, an authorized npm maintainer publishes locally: + +```bash +pnpm run publish:beta +``` + +Append `--otp ` when npm requires a command-line one-time password. The root command builds the workspace and then uses pnpm's recursive `@tokenroll/*` workspace publish flow. It intentionally skips repeated package lifecycle scripts because the root build already produced the artifacts. pnpm packs each public package and rewrites repository `workspace:^` peer ranges to ordinary published ranges. + +## Publish a stable release + +Exit Changesets prerelease mode and merge the stable version PR first. Then manually dispatch the `Release` Action from `main`. The Action rejects prerelease versions and runs the same recursive public-workspace publish command with npm `latest`. + +`Release` is intentionally manual: it requires the repository `NPM_TOKEN` secret but is never triggered by a pull request or push. It does not create a Git tag, GitHub Release, or separate dist-tag mutation. + +## Safety rules + +- Do not publish private `@acplugin/*` packages; root publish scripts filter only `@tokenroll/*`. +- Do not use `npm unpublish` to recover from a failed release. +- Do not create a tag or GitHub Release unless separately authorized. +- If an npm exact version already exists, let pnpm report and skip it; bump the package version before retrying a package that needs changed contents. +- Keep official Platform/Extension manifests on `@tokenroll/acplugin: workspace:^`; pnpm owns the packed peer-range rewrite. +- Run `pnpm run test` and `pnpm run docs:check` for changes that affect behavior, package boundaries, Docs, or Playground. The PR Actions intentionally remain limited to lint and typecheck. diff --git a/llmdoc/guides/release.zh-CN.md b/llmdoc/guides/release.zh-CN.md new file mode 100644 index 0000000..7c69ea7 --- /dev/null +++ b/llmdoc/guides/release.zh-CN.md @@ -0,0 +1,49 @@ +# 发布独立版本化的公开 package + +> [English version](release.md) + +ACPlugin 有九个独立版本化的公开 npm package:主包、六个 Platform package 与 Hooks/MCP Extension。Core、Test、Docs、Playground 均为私有 package,绝不能发布。 + +仓库工具链要求 Node.js `^22.18.0 || >=24.11.0`;已发布 package 另行支持 `^20.19.0 || ^22.13.0 || >=23.5.0`。 + +## 工作流 + +1. 指向 `main` 的功能 PR 为每个受影响的公开 package 提交 Changeset。 +2. PR 创建或更新时,`Lint` 与 `Typecheck` Action 分别运行。 +3. 功能 PR 合并到 `main` 后,`Changelog` 消费待处理 Changeset,创建或更新 `chore(release): version packages`。版本 PR 包含 package manifest 版本、changelog 与生成的公开版本快照,不会发布。 +4. 审核独立版本升级是否符合预期后再合并版本 PR。 +5. beta 在本地发布;稳定版由维护者手工触发 `Release`。 + +`Changelog` 需要通过 `GITHUB_TOKEN` 创建版本 PR,因此仓库必须启用 **Actions → General → Workflow permissions → Allow GitHub Actions to create and approve pull requests**。 + +## 准备 beta + +在已合并的版本 Revision 上先检查 pnpm 的无写入计划: + +```bash +pnpm install --frozen-lockfile +pnpm run publish:beta:dry-run +``` + +确认计划正确后,获得 npm 权限的维护者在本地执行: + +```bash +pnpm run publish:beta +``` + +若 npm 要求命令行一次性验证码,追加 `--otp `。根命令会构建 workspace,随后用 pnpm 递归发布 `@tokenroll/*` 公开 workspace。由于根构建已经产出内容,发布阶段会跳过重复的 package lifecycle scripts。pnpm 会为每个公开 package 打包,并将仓库中的 `workspace:^` peer range 改写为普通已发布范围。 + +## 发布稳定版 + +先退出 Changesets prerelease mode,并合并稳定版版本 PR;随后从 `main` 手动触发 `Release` Action。该 Action 拒绝 prerelease 版本,并使用同一套递归公开 workspace 发布命令写入 npm `latest`。 + +`Release` 有意保持手动:它使用仓库的 `NPM_TOKEN` secret,但绝不因 PR 或 push 自动触发;它不会创建 Git Tag、GitHub Release,也不会执行独立 dist-tag 修改。 + +## 安全规则 + +- 绝不发布私有 `@acplugin/*` package;根发布脚本只筛选 `@tokenroll/*`。 +- 不用 `npm unpublish` 恢复失败发布。 +- 未另获授权时,不创建 Tag 或 GitHub Release。 +- npm 中已存在精确版本时,让 pnpm 报告并跳过;若需要改变该 package 内容,先升级版本再重试。 +- 官方 Platform/Extension manifest 中必须保持 `@tokenroll/acplugin: workspace:^`;打包 peer range 改写由 pnpm 负责。 +- 涉及行为、package 边界、Docs 或 Playground 的改动应运行 `pnpm run test` 与 `pnpm run docs:check`。PR Action 有意只保留 lint 与 typecheck。 diff --git a/llmdoc/guides/usage.md b/llmdoc/guides/usage.md index db138f1..ab95341 100644 --- a/llmdoc/guides/usage.md +++ b/llmdoc/guides/usage.md @@ -1,17 +1,115 @@ -# How to Scan and Convert Claude Code Resources +# Using ACPlugin -A guide for using the `acplugin` CLI to scan a project for Claude Code resources and convert them to other platform formats. The source argument is positional and auto-detects GitHub repos vs local paths. +> [中文对照](usage.zh-CN.md) -1. **Build the project:** Run `npm run build` to compile TypeScript to `dist/`. +ACPlugin projects author one canonical plugin and compile Platform-owned deliveries for Claude Code, Codex, Cursor, Antigravity, OpenCode, and Pi. Node.js `^20.19.0 || ^22.13.0 || >=23.5.0` and pnpm are required. -2. **Scan a local project:** Run `acplugin scan .` or `acplugin scan /path/to/project` to list all discoverable resources. This is read-only and produces no output files. +## Create a project -3. **Scan a GitHub repo:** Run `acplugin scan owner/repo` to download and scan a GitHub repository without cloning. Also supports `github:owner/repo#branch` and full GitHub URLs. Use `-p ` for monorepos. +```bash +pnpm dlx @tokenroll/acplugin init my-plugin --yes +cd my-plugin +pnpm install +pnpm build +``` -4. **Convert to specific platforms:** Run `acplugin convert . --to codex,opencode,cursor,antigravity,pi` to generate output for specified platforms. When `--to` is omitted, an interactive checkbox lets you choose platforms (includes "Antigravity (Google)" and "Pi (pi-coding-agent)" options). +`init` can add the official Hooks and MCP Extensions with `--hooks` and `--mcp`; `--node-runtime` generates a built-in conventional `src/runtime/main.ts` entry without adding another package or factory. Without `--platform`, its scaffolding selection is Claude Code and Codex; it still writes both Platform dependencies, imports, and config entries explicitly. Select any supported set instead: -5. **Convert a marketplace repo:** Run `acplugin convert owner/repo` on a repo with `.claude-plugin/marketplace.json`. An interactive TUI lets you select which plugins to convert. Use `--all` (`-a`) to skip selection. +```bash +pnpm dlx @tokenroll/acplugin init my-plugin --yes \ + --platform claude-code codex cursor antigravity opencode pi \ + --hooks --mcp --node-runtime +``` -6. **Preview without writing:** Add `--dry-run` to see what files would be generated without writing anything to disk. +Every selected Platform is an independent package. An enabled Extension similarly adds its dependency, import, config entry, and empty source directory; `init` never invents a Hook handler or MCP server. The built-in Runtime template is actual neutral executable source and relies on Core's default `src/runtime` discovery. The build runtime has no default package discovery or installation behavior. -7. **Custom output directory:** Use `-o ` to write generated files to a different location. For GitHub sources, output defaults to the current directory instead of the temp download path. +Known `init` input errors use the stable `INIT_INVALID` diagnostic and preserve a safe actionable reason. The specialized error class remains internal and is not exported from the public facade. + +## Author Components + +Put Commands in `src/commands/.md`, Skills in `src/skills//SKILL.md`, and Agents in `src/agents/.md`. Component IDs use lowercase kebab-case. Markdown files require YAML Frontmatter and a non-empty body. + +The required top-level identity belongs directly in `acplugin.config.ts`: + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [claudeCode(), codex()], +}); +``` + +There is no Instructions Component. Hooks and MCP directories are accepted only when their official Extension is enabled. `src/runtime` belongs to Core: direct TS/JS files are entries by convention, while `runtime.entries` can replace discovery and `runtime: false` can disable it. + +The TypeScript config and enabled Hook/MCP descriptors are trusted executable project code. Review them like build scripts. Legacy Migration sources are scanned as untrusted data and are not executed as descriptors. + +## Validate and build + +```bash +pnpm exec acplugin validate +pnpm exec acplugin inspect +pnpm exec acplugin build +pnpm exec acplugin dev +``` + +- `validate` generates and materializes every selected Platform in temporary storage without changing `dist`. +- `inspect` adds Package and Asset details without changing `dist`. +- `build` atomically replaces the complete managed Package set only after every selected Platform succeeds. +- `dev` watches config, canonical resources, Public, descriptors, and the actual Core Module/Build Service graph, including plugin, license, and tsconfig dependencies. Resolved dependencies contribute package roots. Managed bundles reject runtime-computed imports that Rolldown cannot represent in the static module graph. Dev coalesces changes, closes watcher-readiness gaps with a catch-up build, and retains the last successful output after a failed rebuild. + +Common options are `--config`, `--platform `, `--mode`, and `--json`. Strict mode is on by default and is configured through `build.strict` or a Platform factory override. For example, a Codex build containing an Agent fails because Codex can only receive an explicit degraded Skill fallback; use `codex({ strict: false })` when that result is intentional. + +To configure Platforms in an existing project, install and import each package explicitly: + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import antigravity from '@tokenroll/acplugin-platform-antigravity'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; +import cursor from '@tokenroll/acplugin-platform-cursor'; +import openCode from '@tokenroll/acplugin-platform-opencode'; +import pi from '@tokenroll/acplugin-platform-pi'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [claudeCode(), codex(), cursor(), antigravity(), openCode(), pi()], + build: { strict: false }, +}); +``` + +`platforms` is required and `--platform ` only filters IDs already instantiated in that list. The main package does not re-export official factories or provide Platform subpaths. OpenCode output is a workspace overlay and Pi output is an npm package. They are not mislabeled as static Plugins. See the [Platform support matrix](../reference/conversion-matrix.md) before enabling strict multi-Platform builds. + +Codex transforms Commands into explicit `-` Skills. For Plugin `my-plugin`, Command `bootstrap` becomes `my-plugin-bootstrap`; this does not change the canonical Command ID or other Platforms. + +## Public files + +Regular files in `public/` are copied to every target root by default. Use explicit rules when only part of the directory should be copied: + +```ts +public: { + dir: 'public', + copy: [ + { from: 'assets', to: 'assets' }, + { from: 'NOTICE.md', to: 'NOTICE.md' }, + ], +}, +``` + +Symlinks, traversal, collisions, and sources outside approved roots are rejected. + +## Migrate legacy input + +```bash +pnpm exec acplugin migrate ./legacy-project ./new-plugin \ + --name new-plugin \ + --description "Migrated plugin" +``` + +Migration also accepts supported GitHub forms, single Claude plugins, and marketplaces. `--plugin ` emits one project at the destination root; `--all` emits a pnpm workspace. Use `--dry-run` to avoid destination writes and `--strict` to fail on any degraded or unmapped resource. Generated projects run through public config loading and real Extension/Platform validation. Non-portable resources are preserved under `.acplugin-migration/unmapped/` with a report; Migration never writes in place. diff --git a/llmdoc/guides/usage.zh-CN.md b/llmdoc/guides/usage.zh-CN.md new file mode 100644 index 0000000..770f634 --- /dev/null +++ b/llmdoc/guides/usage.zh-CN.md @@ -0,0 +1,115 @@ +# 使用 ACPlugin + +> [English version](usage.md) + +ACPlugin 工程只创作一份规范 Plugin,然后为 Claude Code、Codex、Cursor、Antigravity、OpenCode 和 Pi 编译由各 Platform 拥有的交付产物。运行环境需要 Node.js `^20.19.0 || ^22.13.0 || >=23.5.0` 以及 pnpm。 + +## 创建工程 + +```bash +pnpm dlx @tokenroll/acplugin init my-plugin --yes +cd my-plugin +pnpm install +pnpm build +``` + +`init` 可通过 `--hooks` 和 `--mcp` 加入官方 Hooks 与 MCP Extension;`--node-runtime` 只生成内建约定入口 `src/runtime/main.ts`,不会添加额外 package 或 factory。未传 `--platform` 时,它的脚手架选择是 Claude Code 和 Codex,但仍会显式写入两个 Platform 依赖、import 和配置项。也可以改为选择任意受支持组合: + +```bash +pnpm dlx @tokenroll/acplugin init my-plugin --yes \ + --platform claude-code codex cursor antigravity opencode pi \ + --hooks --mcp --node-runtime +``` + +每个所选 Platform 都是独立 package。启用 Extension 同样只会添加依赖、Import、配置项和空源码目录;`init` 不会伪造 Hook Handler 或 MCP Server。内建 Runtime 模板是真实、中立的可执行源码,依赖 Core 默认的 `src/runtime` 自动发现。构建运行时不会默认发现或安装 package。 + +已知的 `init` 输入错误使用稳定的 `INIT_INVALID` 诊断,并保留安全、可操作的原因;对应的专用错误类型保持内部实现,不从公开门面导出。 + +## 创作 Components + +Commands 放在 `src/commands/.md`,Skills 放在 `src/skills//SKILL.md`,Agents 放在 `src/agents/.md`。Component ID 使用小写 kebab-case。Markdown 文件必须包含 YAML Frontmatter 和非空正文。 + +必需的顶层身份信息直接写在 `acplugin.config.ts`: + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [claudeCode(), codex()], +}); +``` + +ACPlugin 不提供 Instructions Component。只有启用对应官方 Extension 后,工程才允许存在 Hooks 或 MCP 目录。`src/runtime` 由 Core 拥有:一级 TS/JS 文件按约定成为入口,`runtime.entries` 可替换自动发现,`runtime: false` 可关闭该能力。 + +TypeScript 配置以及已启用的 Hook/MCP 描述文件都是受信任、可执行的项目代码,应当像构建脚本一样接受 review。Legacy Migration 来源会作为不可信数据扫描,不会作为描述文件执行。 + +## 验证与构建 + +```bash +pnpm exec acplugin validate +pnpm exec acplugin inspect +pnpm exec acplugin build +pnpm exec acplugin dev +``` + +- `validate` 会在临时目录中生成并物化全部选中 Platform,不修改 `dist`; +- `inspect` 会增加 Package 与 Asset 明细,但不修改 `dist`; +- `build` 只有在全部所选 Platform 成功后才会原子替换完整受管 Package 集合; +- `dev` 监听配置、规范资源、Public、descriptor 和 Core Module/Build Service 的真实图,其中包括 Plugin、license 与 tsconfig 依赖;解析后的依赖会贡献 package root。托管 Bundle 中 Rolldown 无法表示在静态模块图内的运行时计算 import 会被拒绝。Dev 会合并变更,用 watcher ready 后的补偿构建关闭竞态窗口,并在重建失败时保留最后一次成功输出。 + +通用选项包括 `--config`、`--platform `、`--mode` 和 `--json`。默认启用严格模式,并通过 `build.strict` 或 Platform factory override 配置。例如,包含 Agent 的 Codex 构建会失败,因为 Codex 只能接收显式降级的 Skill 回退;当该结果符合预期时,可使用 `codex({ strict: false })` 明确接受。 + +现有工程需要显式安装并导入每个 Platform package: + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import antigravity from '@tokenroll/acplugin-platform-antigravity'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; +import cursor from '@tokenroll/acplugin-platform-cursor'; +import openCode from '@tokenroll/acplugin-platform-opencode'; +import pi from '@tokenroll/acplugin-platform-pi'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [claudeCode(), codex(), cursor(), antigravity(), openCode(), pi()], + build: { strict: false }, +}); +``` + +`platforms` 是必填项,`--platform ` 只会筛选该列表中已经实例化的 ID。主包不重新导出官方工厂,也不提供 Platform subpath。OpenCode 产物是 Workspace Overlay,Pi 产物是 npm Package,不会被错误标记为静态 Plugin。启用严格多平台构建前应先查看[平台支持矩阵](../reference/conversion-matrix.zh-CN.md)。 + +Codex 默认把 Command 转换为显式的 `-` Skill。对于 Plugin `my-plugin`,Command `bootstrap` 会生成 `my-plugin-bootstrap`;这不会改变 canonical Command ID 或其他 Platform。 + +## Public 文件 + +默认情况下,`public/` 中的普通文件会复制到每个目标根目录。如果只需复制其中一部分,可以使用显式规则: + +```ts +public: { + dir: 'public', + copy: [ + { from: 'assets', to: 'assets' }, + { from: 'NOTICE.md', to: 'NOTICE.md' }, + ], +}, +``` + +符号链接、目录穿越、路径冲突以及可信根目录之外的来源都会被拒绝。 + +## 迁移旧工程 + +```bash +pnpm exec acplugin migrate ./legacy-project ./new-plugin \ + --name new-plugin \ + --description "Migrated plugin" +``` + +Migration 也支持受支持的 GitHub 来源格式、单个 Claude Plugin 和 Marketplace。`--plugin ` 在目标根输出一个工程,`--all` 输出 pnpm workspace。使用 `--dry-run` 可避免写入目标目录,使用 `--strict` 可在存在任何降级或未映射资源时失败。生成工程会经过公开配置加载和真实 Extension/Platform 验证。不可移植资源会随报告保存在 `.acplugin-migration/unmapped/`;Migration 绝不会原地修改来源。 diff --git a/llmdoc/index.md b/llmdoc/index.md index 5c203fb..31d84d8 100644 --- a/llmdoc/index.md +++ b/llmdoc/index.md @@ -1,24 +1,28 @@ -# acplugin - Documentation Index +# ACPlugin documentation -## Project Summary +ACPlugin is a canonical AI Plugin framework and CLI. Authors write Commands, Skills, Agents, and optional Extension sources once, then build Platform-owned deliveries for Claude Code, Codex, Cursor, Antigravity, OpenCode, and Pi. -CLI tool that converts Claude Code plugins (skills, instructions, MCP configs, agents, commands, hooks) to compatible formats for Codex CLI, OpenCode, Cursor IDE, Google Antigravity, and Pi (pi-coding-agent). Supports local projects, single plugins, marketplace repos, and direct GitHub download. +关键稳定文档同时维护英文基准与中文对照;行为变化需要同步更新两种语言。 -## Document Map +## Overview -### Overview +- [Project overview](overview/project.md) · [项目概览](overview/project.zh-CN.md) — product boundary, packages, runtime, and Migration isolation. -- [Project Overview](overview/project.md) - What acplugin is, supported input formats, and tech stack. +## Guides -### Guides +- [Using ACPlugin](guides/usage.md) · [使用 ACPlugin](guides/usage.zh-CN.md) — scaffold, author, validate, build, and migrate. +- [按 Package 代码导览](guides/package-code-tour.zh-CN.md) — 每个 workspace package 的职责、架构、数据流、实现伪代码与修改入口。 +- [Release guide](guides/release.md) · [手动发布指南](guides/release.zh-CN.md) — independent public-package verification and fully manual publishing. -- [CLI Usage](guides/usage.md) - How to scan and convert resources from local paths or GitHub repos. -- [npm Release](guides/release.md) - How tag-driven GitHub Actions publishing to npm works. +## Architecture -### Architecture +- [System architecture](architecture/system.md) · [系统架构](architecture/system.zh-CN.md) — Core-owned lifecycle, Packages, unordered Contributions, Asset capabilities, and managed output transaction. +- [ADR-0001: Session close and deterministic rebuilds](architecture/decisions/0001-lifecycle-determinism-and-cache.md) · [ADR-0001:Session 关闭与确定性重建](architecture/decisions/0001-lifecycle-determinism-and-cache.zh-CN.md) +- [ADR-0002: unordered Extension contributions](architecture/decisions/0002-extension-contribution-order.md) · [ADR-0002:无序 Extension Contribution](architecture/decisions/0002-extension-contribution-order.zh-CN.md) +- [ADR-0003: Node toolchain and runtime support](architecture/decisions/0003-node-toolchain-and-runtime-support.md) · [ADR-0003:Node 工具链与运行时支持](architecture/decisions/0003-node-toolchain-and-runtime-support.zh-CN.md) +- [ADR-0004: first-class Platform packages](architecture/decisions/0004-first-class-platform-packages.md) · [ADR-0004:Platform 是一等独立生态包](architecture/decisions/0004-first-class-platform-packages.zh-CN.md) -- [System Architecture](architecture/system.md) - Source resolution, scanner pipeline, TUI selection, converter-writer flow. +## Reference -### Reference - -- [Conversion Matrix](reference/conversion-matrix.md) - Resource type support per target platform, input formats, and source types. +- [Target support matrix](reference/conversion-matrix.md) · [目标支持矩阵](reference/conversion-matrix.zh-CN.md) — native, transformed, and degraded target capabilities plus implementation ownership. +- [Domain glossary](reference/domain-glossary.md) · [领域术语](reference/domain-glossary.zh-CN.md) — build, commit, cleanup, determinism, cacheability, and Extension ordering terms. diff --git a/llmdoc/memory/reflections/2026-07-29-agent-plugin-format-audit.md b/llmdoc/memory/reflections/2026-07-29-agent-plugin-format-audit.md index 84be3a1..ca179f1 100644 --- a/llmdoc/memory/reflections/2026-07-29-agent-plugin-format-audit.md +++ b/llmdoc/memory/reflections/2026-07-29-agent-plugin-format-audit.md @@ -2,7 +2,7 @@ ## Task -- 审查 acplugin 当前转换实现,并用 2026-07-29 的官方资料确认 Claude Code、Codex、Cursor、OpenCode、Gemini CLI 与 Antigravity 的最新扩展格式。 +- 审查 ACPlugin 当前转换实现,并用 2026-07-29 的官方资料确认 Claude Code、Codex、Cursor、OpenCode、Gemini CLI 与 Antigravity 的最新扩展格式。 - 对照真实 marketplace、现有测试和并行工作树改动,区分已确认缺口、未提交修正与仍需验证的判断。 ## Expected vs Actual diff --git a/llmdoc/overview/project.md b/llmdoc/overview/project.md index 123e544..9019ae2 100644 --- a/llmdoc/overview/project.md +++ b/llmdoc/overview/project.md @@ -1,16 +1,47 @@ -# acplugin +# Project Overview -## 1. Identity +> [中文对照](project.zh-CN.md) -- **What it is:** A CLI tool that converts Claude Code plugin configurations into equivalent formats for Codex CLI, OpenCode, Cursor IDE, Google Antigravity, and Pi (pi-coding-agent). -- **Purpose:** Enables developers to maintain a single Claude Code configuration and automatically generate compatible configurations for other AI coding platforms. +## Identity -## 2. High-Level Description +ACPlugin is a Rolldown-based AI Plugin framework and CLI. Authors maintain one canonical project and continuously compile validated Packages for Claude Code, Codex, Cursor, Antigravity, OpenCode, and Pi. -acplugin follows a scan-convert-write pipeline. It scans a local directory or a GitHub repository for Claude Code resources (skills, instructions, MCP server configs, agents, commands, and hooks), then converts each resource type into the target platform's native format, and writes the output files. Conversion is one-way (Claude Code to others, never bidirectional). Claude-specific fields that have no equivalent on a target platform are preserved as HTML comments or generate compatibility warnings. A model mapping module (`src/utils/model.ts`) translates Claude model names to platform equivalents (e.g., `gpt-5.6-sol` for Codex, `gemini-3.1-pro-preview`/`gemini-3.6-flash` for Antigravity). +The independently versioned public packages are: -The tool supports three input formats: standard Claude Code project layout (`.claude/` directory), single plugin (`.claude-plugin/plugin.json`), and multi-plugin marketplace (`.claude-plugin/marketplace.json`). Sources can be local paths or GitHub repositories (auto-detected from `owner/repo` syntax). For marketplace repos, an interactive TUI allows selecting which plugins and target platforms to convert. Cursor output uses `.cursor-plugin/` format with `plugin.json` manifest and resources at plugin root (`skills/`, `agents/`, `commands/`, `rules/`, `mcp.json`); the plugin/marketplace format was introduced in Cursor 3.9 (2026-06). OpenCode generates `.opencode/agents/*.md` with `mode: subagent`, `steps`, `permission` fields; Antigravity generates `.agents/agents/*.md` (Claude tool list preserved as a comment, since its internal tool identifiers are unpublished). GitHub Actions also handles automation around the project itself: `.github/workflows/acplugin.yml` runs conversion on push to main, and `.github/workflows/publish-npm.yml` publishes `@disdjj/acplugin` to npm from matching `v*` tags via npm Trusted Publishing. +- `@tokenroll/acplugin` +- six `@tokenroll/acplugin-platform-*` packages +- `@tokenroll/acplugin-extension-hooks` +- `@tokenroll/acplugin-extension-mcp` -**Tech Stack:** TypeScript, Node.js, Commander.js, gray-matter, @iarna/toml, glob, @inquirer/prompts, chalk, ora. +Core, integration tests, Docs, and Playground are private workspaces. `@tokenroll/acplugin` bundles Core and exposes two intentional boundaries: the root author/programmatic API and `@tokenroll/acplugin/sdk` for Platform and Extension implementations. Official integrations use the SDK through a peer dependency; public tarballs never depend on `@acplugin/*`. -**Entry point:** `src/index.ts` - CLI binary registered as `acplugin` in package.json. +## Authoring boundary + +Canonical Components are Commands, Skills, and Agents. `acplugin.config.ts` defines project metadata, explicit Platform instances, Public mappings, optional Extensions, the built-in Node Runtime, and build strictness. Instructions are intentionally outside the installable Plugin boundary. + +Hooks and MCP are optional Extensions. Each discovers and builds its author resources once through Core-owned Module, Compiler, Asset, Execution, and Watch services, then contributes add-only Package fields and Assets for supported Platforms. + +Node Runtime is a Framework Resource, not an Extension package. Direct files under `src/runtime/` are entries by convention; explicit `runtime.entries` replaces automatic discovery. Core compiles each entry once with the `portable-node` profile and capable Platforms inherit the same Asset references and bytes. Unsupported Platforms report the capability and emit no pseudo runtime. + +`platforms` is required. Builds use explicitly imported instances; the main package never discovers implementations by ID. `init` defaults to explicit Claude Code and Codex dependencies/imports when no platform option is supplied. Each Platform owns canonical conversion, base Package Documents and Assets, final Package identity, optional distributions, candidate validation, and compatibility reporting. + +## Runtime and tooling + +- Node.js >=20 and ESM-only TypeScript 7 package sources +- pnpm workspace without Turborepo +- Commander.js and `@inquirer/prompts` for CLI/TUI +- tsdown for package bundles and declarations +- one Core-owned Rolldown Module/Compiler service for config, descriptors, portable Node bundles, and managed third-party builds +- one Core-owned Chokidar watcher behind `Project.dev()` +- Vitest for private repository tests +- VitePress 1.6 and TypeDoc 0.28 for private documentation + +The workspace catalog maps package `tsc` commands to `@typescript/native`. Tools that still require the legacy Compiler API use the isolated `@typescript/typescript6` alias. Production package typechecking remains on TypeScript 7. + +The CLI and author facade live in `packages/acplugin/src/cli.ts`, `src/cli/`, `src/index.ts`, and `src/author/`; `src/sdk.ts` is the only Integration implementation entry. Core's fixed lifecycle is implemented by `packages/core/src/lifecycle/build-session.ts`, while `Project.dev()` delegates every rebuild round to that same BuildSession. + +## Migration boundary + +`acplugin migrate` is dynamically imported and isolated under `packages/acplugin/src/migration/`. Tolerant legacy GitHub and Claude/plugin reading remains under `migration/legacy/` only for migration input. Normal CLI startup, Core, Platforms, and Extensions do not import Migration. + +Migration never writes in place. Content that cannot be mapped safely is preserved under `.acplugin-migration/unmapped/` with a stable report; it is not fabricated into canonical Hooks, MCP implementations, Instructions, or external-command wrappers. diff --git a/llmdoc/overview/project.zh-CN.md b/llmdoc/overview/project.zh-CN.md new file mode 100644 index 0000000..207ec5b --- /dev/null +++ b/llmdoc/overview/project.zh-CN.md @@ -0,0 +1,47 @@ +# 项目概览 + +> [English version](project.md) + +## 项目定位 + +ACPlugin 是一套基于 Rolldown 的 AI Plugin 框架和 CLI。作者维护一份规范工程,持续为 Claude Code、Codex、Cursor、Antigravity、OpenCode 和 Pi 编译经过完整校验的 Package。 + +独立版本化的公开 package 包括: + +- `@tokenroll/acplugin` +- 六个 `@tokenroll/acplugin-platform-*` +- `@tokenroll/acplugin-extension-hooks` +- `@tokenroll/acplugin-extension-mcp` + +Core、集成测试、Docs 与 Playground 都是私有 workspace。`@tokenroll/acplugin` 内联 Core,并提供两个有意分离的入口:根入口面向普通作者和程序化调用方,`@tokenroll/acplugin/sdk` 面向 Platform/Extension 实现。官方集成通过主包 peer 使用 SDK;任何公开 tarball 都不依赖 `@acplugin/*`。 + +## 创作边界 + +Canonical Component 包括 Command、Skill 和 Agent。`acplugin.config.ts` 定义工程元数据、显式 Platform 实例、Public 映射、可选 Extension、内建 Node Runtime 与构建严格度。Instructions 被有意排除在可安装 Plugin 边界之外。 + +Hooks 与 MCP 是可选 Extension。每个 Extension 通过 Core 独占的 Module、Compiler、Asset、Execution 和 Watch 服务发现并构建一次作者资源,再为支持的平台 add-only 贡献 Package 字段与 Asset。 + +Node Runtime 是 Framework Resource,不是 Extension package。默认把 `src/runtime/` 一级文件作为入口;显式 `runtime.entries` 会完整替换自动发现。Core 使用 `portable-node` profile 对每个入口只编译一次,具备能力的 Platform 继承相同的 Asset 引用与字节;不支持的平台必须报告该能力且不能生成伪 Runtime。 + +`platforms` 必填。构建只使用显式导入的实例,主包不会按 ID 发现实现。未指定平台时,`init` 默认显式安装并导入 Claude Code 与 Codex。每个 Platform 拥有 canonical 转换、base Package 的 Document/Asset、最终 Package 身份、可选 Distribution、候选校验和兼容性报告。 + +## 运行时与工具链 + +- Node.js >=20,Package 源码使用 ESM-only TypeScript 7 +- pnpm workspace,不使用 Turborepo +- Commander.js 与 `@inquirer/prompts` 提供 CLI/TUI +- tsdown 负责 Package bundle 与声明文件 +- Core 唯一的 Rolldown Module/Compiler Service 负责配置、descriptor、portable Node bundle 和受管第三方构建 +- Core 通过 `Project.dev()` 独占唯一 Chokidar watcher +- Vitest 负责私有仓库测试 +- VitePress 1.6 与 TypeDoc 0.28 负责私有文档 + +Workspace catalog 把各 Package 的 `tsc` 映射到 `@typescript/native`。仍依赖旧 Compiler API 的工具使用隔离的 `@typescript/typescript6` 别名;生产 Package 的类型检查保持 TypeScript 7。 + +CLI 与作者 façade 位于 `packages/acplugin/src/cli.ts`、`src/cli/`、`src/index.ts` 和 `src/author/`;`src/sdk.ts` 是 Integration 实现唯一入口。Core 固定生命周期由 `packages/core/src/lifecycle/build-session.ts` 实现,`Project.dev()` 的每个重建轮次也委托给同一 BuildSession。 + +## Migration 边界 + +`acplugin migrate` 使用动态 import,并隔离在 `packages/acplugin/src/migration/`。容错型 legacy GitHub 与 Claude/plugin 读取仅在 `migration/legacy/` 中服务迁移输入;正常 CLI 启动、Core、Platform 与 Extension 都不导入 Migration。 + +Migration 不原地写入。无法安全映射的内容会保存在 `.acplugin-migration/unmapped/` 并生成稳定报告;不会伪造成 Canonical Hook、MCP 实现、Instructions 或外部命令包装。 diff --git a/llmdoc/reference/conversion-matrix.md b/llmdoc/reference/conversion-matrix.md index 8aeb99c..acdb8b5 100644 --- a/llmdoc/reference/conversion-matrix.md +++ b/llmdoc/reference/conversion-matrix.md @@ -1,30 +1,86 @@ -# Conversion Matrix - -This document summarizes which Claude Code resource types are supported by each target platform, the supported input formats, and source types. - -## 1. Core Summary - -acplugin converts six Claude Code resource types (skills, instructions, MCP configs, agents, commands, hooks) across five target platforms (Codex, OpenCode, Cursor, Antigravity, Pi). Codex/OpenCode/Cursor/Antigravity support agents natively via subagent files; Pi does not. Pi (pi-coding-agent, earendil-works/pi) is a minimal terminal harness whose only native file formats are Claude-style Skills (`.pi/skills//SKILL.md`) and instructions (`AGENTS.md`); Commands degrade to prompt templates (`.pi/prompts/*.md`), and MCP/Agents/Hooks have no Pi file format (extended via TypeScript extensions) so the Pi writer emits warnings instead. Model names pass through unchanged for Pi. Cursor outputs `.cursor-plugin/` format: `plugin.json` manifest + `skills/`, `agents/`, `commands/`, `rules/`, `mcp.json` at plugin root (plugin/marketplace format introduced in Cursor 3.9, 2026-06). Antigravity maps (CLI workspace convention, plural `.agents/`): Skills → `.agents/skills/`, Instructions → `GEMINI.md`, MCP → `.agents/mcp_config.json` (remote servers use `serverUrl`), Agents → `.agents/agents/*.md` (Claude tool list preserved as a comment — Antigravity's internal tool identifiers are unpublished), Commands → Skills. OpenCode agents output `.opencode/agents/*.md` (fields: `mode: subagent`, `steps`, `permission`); OpenCode MCP uses a single `command` string array + `environment` key + `enabled`. Model names are mapped via `src/utils/model.ts` (Codex → `gpt-5.6-sol`/`gpt-5.6-terra`, Antigravity → `gemini-3.1-pro-preview`/`gemini-3.6-flash`). - -## 2. Source of Truth - -- **Type Definitions:** `src/types.ts` - All resource types (`Skill`, `Instruction`, `MCPConfig`, `Agent`, `Command`, `Hooks`), plugin types (`PluginMeta`, `PluginScanResult`), and result types (`ScanResult`, `ConvertResult`, `ConvertedFile`). `PluginMeta` includes optional `displayName`, `homepage`, `repository`, `license`, `keywords` fields. -- **Integration Tests:** `src/__tests__/superpowers-integration.test.ts` - 51 integration tests using real superpowers plugin data covering full pipeline. -- **GitHub Source Resolution:** `src/github.ts` - Parsing and downloading GitHub repos. Supported formats: `owner/repo`, `github:owner/repo#branch`, full URLs. -- **Plugin Scanner:** `src/scanner/plugin.ts` - Plugin format detection and scanning. Marketplace: `.claude-plugin/marketplace.json`. Single plugin: `.claude-plugin/plugin.json`. Plugin layout: `skills/`, `agents/`, `commands/`, `hooks/` directly in plugin root. -- **Project Scanner:** `src/scanner/claude.ts` - Standard Claude Code project scanning (`.claude/` directory layout). -- **TUI Selection:** `src/tui.ts` - Interactive plugin and platform selection via @inquirer/prompts. -- **Skill Converter:** `src/converter/skill.ts` - Platform-specific skill conversion logic. -- **Instruction Converter:** `src/converter/instructions.ts` - CLAUDE.md / rules conversion to AGENTS.md or .mdc. -- **MCP Converter:** `src/converter/mcp.ts` - MCP server config conversion to config.toml / opencode.json / mcp.json (Cursor plugin format). -- **Agent Converter:** `src/converter/agent.ts` - Native agent conversion for Codex, OpenCode, Cursor, and Antigravity. Cursor: `agents/*.md` (`name`, `description`, `model`, `readonly`). OpenCode: `.opencode/agents/*.md` (`mode: subagent`, `steps`, `permission`). Antigravity: `.agents/agents/*.md` (Claude tool list preserved as an HTML comment; no `allowed-tools` allowlist emitted because Antigravity's internal tool identifiers are unpublished). The `'pi'` case throws because Pi has no subagent format and its writer never calls this converter. -- **Command Converter:** `src/converter/command.ts` - Command conversion across platforms. Antigravity converts commands to skills. -- **Hooks Converter:** `src/converter/hooks.ts` - Hook conversion with compatibility warnings for non-portable events. Cursor hooks get dedicated conversion: PascalCase → camelCase event names, `${CLAUDE_PLUGIN_ROOT}` stripped to relative paths, output as `hooks/hooks-cursor.json` with `{ version: 1 }` format. -- **Model Mapper:** `src/utils/model.ts` - Claude model → platform model mapping. Codex: `gpt-5.6-sol` (default), `gpt-5.6-terra` (haiku tier). Antigravity: `gemini-3.1-pro-preview`, `gemini-3.6-flash`. OpenCode/Cursor: passthrough. -- **Codex Writer:** `src/writer/codex.ts` - Codex output orchestration. -- **OpenCode Writer:** `src/writer/opencode.ts` - OpenCode output orchestration. -- **Cursor Writer:** `src/writer/cursor.ts` - Cursor plugin format output. Generates `.cursor-plugin/plugin.json` manifest with passthrough of `displayName`, `homepage`, `repository`, `license`, `keywords` and `hooks` field. Output paths remapped from `.cursor/` to plugin root: `skills/`, `agents/`, `commands/`, `rules/`, `mcp.json`. -- **Antigravity Writer:** `src/writer/antigravity.ts` - Antigravity (Google) output orchestration. -- **Pi Writer:** `src/writer/pi.ts` (`generatePi`) - Pi (pi-coding-agent) output orchestration. Converts Skills → `.pi/skills/`, Instructions → `AGENTS.md`, Commands → `.pi/prompts/*.md` (prompt templates). Emits warnings for MCP, agents, and hooks (no Pi file format). The MCP/agent/hooks converters throw or return null for the `'pi'` case since the writer never calls them. -- **GitHub Action:** `.github/workflows/acplugin.yml` - CI workflow using `TokenRollAI/acplugin-action@v1`. Triggers on push to main when `.claude/` or `CLAUDE.md` changes. Auto-converts to all 5 platforms. -- **System Architecture:** `/llmdoc/architecture/system.md` - Full pipeline and execution flow. +# Platform support matrix + +> [中文对照](conversion-matrix.zh-CN.md) + +This matrix describes canonical ACPlugin 1.0 builds. Tolerant conversion code below `packages/acplugin/src/migration/legacy/` belongs only to Migration and is not another build path. + +## Delivery and Components + +| Capability | Claude Code | Codex | Cursor | Antigravity | OpenCode | Pi | +| --- | --- | --- | --- | --- | --- | --- | +| Primary Package | Installable Plugin | Installable Plugin | Installable Plugin | Installable Plugin | Workspace overlay | npm package | +| Skill | Native | Native | Native | Native | Native | Native | +| Command | Native Command | Transform to explicit `-` Skill | Native Command | Transform to explicit `command-` Skill | Native workspace Command | Transform to Prompt Template | +| Agent | Native Agent | Degraded `agent-` guidance Skill | Native Subagent; some model/capability fields degrade | Degraded `agent-` guidance Skill | Native Subagent; capabilities transform to tools/permissions | Degraded `agent-` guidance Skill | +| Public files | Plugin-root copy | Plugin-root copy | Plugin-root copy | Plugin-root copy | Workspace-root copy | Package-root copy | +| Built-in Node Runtime | Native Plugin-local Node 20 ESM | Native Plugin-local Node 20 ESM | Unsupported | Unsupported | Unsupported | Unsupported | +| Separate Marketplace distribution | Optional | Optional | Not generated | Not generated | Not applicable | Not applicable | + +`native` means the Platform has an equivalent installable resource. `transform` means ACPlugin emits a different native resource while preserving the workflow intent. `degraded` means an important runtime guarantee cannot be preserved. Strict mode rejects any degraded or unsupported result; set `strict: false` on the affected Platform factory only after reviewing the structured compatibility report. + +OpenCode is intentionally a workspace overlay and does not receive a fabricated generic `package.json`. Pi is a real npm package and its manifest must not leak workspace/private fields. Antigravity emits only Manifest fields confirmed by its public contract. + +When a Codex Command body uses `{{arguments}}`, the fallback Skill replaces it with explicit invocation guidance and reports an independent `arguments/transform` capability. A declared `argumentHint` remains a separate degraded capability because Codex Skill metadata has no equivalent hint UI. + +Codex uses `-` as the generated Skill ID. The final ID is validated with the native and Agent fallback Skill namespace before Package creation. + +## Platform Component Contributions + +An Extension may map a private resource into an opaque JSON payload owned by a target Platform. This is not a Canonical Component, a raw Manifest patch, an Extension ordering mechanism, or a fallback protocol. The Platform validates and renders the payload in finalization, while Core keeps the merge and provenance generic. + +| Platform | Private native contribution | +| --- | --- | +| Claude Code | Native Agent at `agents/.md`; the Platform controls the `agents` Manifest field. | +| Cursor | Native Subagent at `agents/.md`; the Platform controls the `agents` Manifest glob. | +| OpenCode | Native workspace Subagent at `.opencode/agents/.md`; no config patch is needed. | +| Codex | Unsupported; a non-empty contribution fails, with no generated `agent-*` Skill. | +| Antigravity | Unsupported; a non-empty contribution fails, with no generated Skill fallback. | +| Pi | Unsupported; a non-empty contribution fails, with no generated guidance Skill. | + +The first supported payloads are the respective Platform package's native Agent types. Schema, identity, case/NFC collision policy, output paths, and candidate validation remain Platform-owned. Component-driven Assets and finalization Documents expose only stable Extension owner/subject provenance in schema-v3 reports. + +## Hooks Extension + +| Portable event | Claude Code | Codex | Cursor | Antigravity | OpenCode | Pi | +| --- | --- | --- | --- | --- | --- | --- | +| `SessionStart` | Native | Native | Transform | Native | Native | Native | +| `SessionEnd` | Native | Native | Transform | Native | Degraded | Native | +| `UserPromptSubmit` | Native | Native | Transform | Unsupported | Native | Native | +| `PreToolUse` | Native | Native | Transform | Native | Native | Native | +| `PermissionRequest` | Native | Native | Unsupported | Unsupported | Unsupported | Unsupported | +| `PostToolUse` | Native | Native | Transform | Native | Native | Native | +| `PreCompact` | Native | Native | Transform | Native | Unsupported | Native | +| `PostCompact` | Native | Native | Unsupported | Unsupported | Native | Native | +| `SubagentStart` | Native | Native | Transform | Unsupported | Unsupported | Unsupported | +| `SubagentStop` | Native | Native | Transform | Unsupported | Unsupported | Unsupported | +| `Stop` | Native | Native | Transform | Unsupported | Degraded | Degraded | + +Platform-only events remain explicitly scoped and do not expand the portable union. A supported event can still report a field-level degradation when the host ignores a meaningful matcher or has no stable status-message field. Empty Hooks produce no runtime Asset or Manifest field. + +## MCP Extension + +| Transport | Claude Code | Codex | Cursor | Antigravity | OpenCode | Pi | +| --- | --- | --- | --- | --- | --- | --- | +| Remote Streamable HTTP | Native | Native | Native | Native | Native | Unsupported | +| Bundled local stdio | Native | Native | Unsupported | Unsupported | Native local process | Unsupported | + +Remote MCP authoring is declarative: the author supplies an endpoint and secret references. Local stdio MCP is executable content: the author supplies a complete `server.ts`, which the Extension bundles once as Node 20 ESM through Core's `portable-node` Compiler profile and reuses only on Platforms with a verified install-root contract. The bounded initialize/tools-list smoke runs in both development and production; no Contributor reads secret environment values during build. + +## Source and output ownership + +| Concern | Source of truth | +| --- | --- | +| Config, author types, and Integration SDK contracts | `packages/core/src/contracts/`, `api/definitions.ts`, `api/author.ts`, `api/integration.ts` | +| Canonical/Public/Runtime/Extension discovery | `packages/core/src/resources/` | +| Fixed lifecycle and Platform isolation | `packages/core/src/lifecycle/build-session.ts` | +| Package, Document, Contribution, and report registries | `packages/core/src/package/` | +| Transactional output | `packages/core/src/output/transaction.ts` | +| Platform output contracts | `packages/platforms//src/` | +| Hooks discovery, compilation, and Platform Contributors | `packages/extensions/hooks/src/` | +| MCP discovery, compilation, and Platform Contributors | `packages/extensions/mcp/src/` | +| Public facade, SDK, and Project config loading | `packages/acplugin/src/index.ts`, `sdk.ts`, `author/project.ts` | +| CLI and isolated Migration boundary | `packages/acplugin/src/cli.ts`, `cli/`, `migration/` | + +Platforms own base Package paths, Documents, manifests, schemas, final Package identity, distributions, and candidate validation. Extension Contributors all read the same immutable base Package and may add owned Assets, fill declared add-only Document extension points, and report compatibility. They cannot observe other Contributions, replace a Platform, or write `dist` directly. + +Official contracts were last rechecked on 2026-08-06 against [Claude Code Hooks](https://code.claude.com/docs/en/hooks), [Codex Hooks](https://learn.chatgpt.com/docs/hooks), the [Cursor Plugin Schema](https://github.com/cursor/plugins/blob/main/schemas/plugin.schema.json), [Antigravity Plugins](https://antigravity.google/docs/plugins?app=cli), [OpenCode Plugins](https://opencode.ai/docs/plugins/), [OpenCode MCP](https://opencode.ai/docs/mcp-servers/), and [Pi Packages](https://pi.dev/docs/latest/packages). diff --git a/llmdoc/reference/conversion-matrix.zh-CN.md b/llmdoc/reference/conversion-matrix.zh-CN.md new file mode 100644 index 0000000..4c2c196 --- /dev/null +++ b/llmdoc/reference/conversion-matrix.zh-CN.md @@ -0,0 +1,86 @@ +# Platform 支持矩阵 + +> [English version](conversion-matrix.md) + +本矩阵描述规范 ACPlugin 1.0 构建。`packages/acplugin/src/migration/legacy/` 下的容错转换代码只属于 Migration,不是另一条构建路径。 + +## 交付形态与 Component + +| 能力 | Claude Code | Codex | Cursor | Antigravity | OpenCode | Pi | +| --- | --- | --- | --- | --- | --- | --- | +| 主 Package | 可安装 Plugin | 可安装 Plugin | 可安装 Plugin | 可安装 Plugin | Workspace Overlay | npm Package | +| Skill | 原生 | 原生 | 原生 | 原生 | 原生 | 原生 | +| Command | 原生 Command | 转换为显式 `-` Skill | 原生 Command | 转换为显式 `command-` Skill | 原生 Workspace Command | 转换为 Prompt Template | +| Agent | 原生 Agent | 降级为 `agent-` 指导 Skill | 原生 Subagent;部分模型/能力字段降级 | 降级为 `agent-` 指导 Skill | 原生 Subagent;能力转换为 tools/permissions | 降级为 `agent-` 指导 Skill | +| Public 文件 | 复制到 Plugin 根 | 复制到 Plugin 根 | 复制到 Plugin 根 | 复制到 Plugin 根 | 复制到 Workspace 根 | 复制到 Package 根 | +| 内建 Node Runtime | 原生 Plugin-local Node 20 ESM | 原生 Plugin-local Node 20 ESM | 不支持 | 不支持 | 不支持 | 不支持 | +| 独立 Marketplace 分发 | 可选 | 可选 | 不生成 | 不生成 | 不适用 | 不适用 | + +`原生` 表示 Platform 有等价的可安装资源;`转换` 表示生成另一种原生资源并保留工作流意图;`降级` 表示关键运行时保证无法完整保留。严格模式会拒绝 degraded/unsupported;只有在审阅结构化兼容性报告后,才应在受影响的 Platform factory 上配置 `strict: false`。 + +OpenCode 明确是 Workspace Overlay,不会收到伪造的通用 `package.json`。Pi 是真实 npm Package,其 Manifest 不得泄漏 workspace/private 字段。Antigravity 只输出公开契约已经确认的 Manifest 字段。 + +当 Codex Command 正文使用 `{{arguments}}` 时,回退 Skill 会把它替换为显式调用指引,并独立报告 `arguments/transform` 能力。作者声明的 `argumentHint` 仍是另一项 degraded 能力,因为 Codex Skill 元数据没有等价的参数提示 UI。 + +Codex 使用 `-` 作为 generated Skill ID,并在 Package 创建前与原生 Skill、Agent fallback Skill 共用同一命名空间校验。 + +## Platform Component Contribution + +Extension 可以把私有资源映射为目标 Platform 拥有的不透明 JSON payload。这不是 Canonical Component、raw Manifest patch、Extension 排序机制或 fallback 协议。Platform 在 finalization 中校验和渲染 payload,Core 只保持通用 merge 与 provenance。 + +| Platform | 私有原生 contribution | +| --- | --- | +| Claude Code | 原生 Agent,路径为 `agents/.md`;Platform 控制 `agents` Manifest 字段。 | +| Cursor | 原生 Subagent,路径为 `agents/.md`;Platform 控制 `agents` Manifest glob。 | +| OpenCode | 原生 workspace Subagent,路径为 `.opencode/agents/.md`;无需 patch config。 | +| Codex | 不支持;非空 contribution 失败,不生成 `agent-*` Skill。 | +| Antigravity | 不支持;非空 contribution 失败,不生成 Skill fallback。 | +| Pi | 不支持;非空 contribution 失败,不生成指导型 Skill。 | + +首期支持的 payload 是各 Platform package 的原生 Agent type。schema、identity、大小写/NFC 冲突策略、输出路径和 candidate validation 始终属于 Platform。由 contribution 决定的 Asset 与 finalization Document 只在 schema-v3 report 中公开稳定的 Extension owner/subject provenance。 + +## Hooks Extension + +| 可移植事件 | Claude Code | Codex | Cursor | Antigravity | OpenCode | Pi | +| --- | --- | --- | --- | --- | --- | --- | +| `SessionStart` | 原生 | 原生 | 转换 | 原生 | 原生 | 原生 | +| `SessionEnd` | 原生 | 原生 | 转换 | 原生 | 降级 | 原生 | +| `UserPromptSubmit` | 原生 | 原生 | 转换 | 不支持 | 原生 | 原生 | +| `PreToolUse` | 原生 | 原生 | 转换 | 原生 | 原生 | 原生 | +| `PermissionRequest` | 原生 | 原生 | 不支持 | 不支持 | 不支持 | 不支持 | +| `PostToolUse` | 原生 | 原生 | 转换 | 原生 | 原生 | 原生 | +| `PreCompact` | 原生 | 原生 | 转换 | 原生 | 不支持 | 原生 | +| `PostCompact` | 原生 | 原生 | 不支持 | 不支持 | 原生 | 原生 | +| `SubagentStart` | 原生 | 原生 | 转换 | 不支持 | 不支持 | 不支持 | +| `SubagentStop` | 原生 | 原生 | 转换 | 不支持 | 不支持 | 不支持 | +| `Stop` | 原生 | 原生 | 转换 | 不支持 | 降级 | 降级 | + +Platform-only 事件保持显式平台限定,不会扩充可移植事件联合。即使事件受支持,当宿主忽略 meaningful matcher 或没有稳定状态消息字段时,仍会产生字段级降级。空 Hooks 不会生成运行时 Asset 或 Manifest 字段。 + +## MCP Extension + +| 传输 | Claude Code | Codex | Cursor | Antigravity | OpenCode | Pi | +| --- | --- | --- | --- | --- | --- | --- | +| 远程 Streamable HTTP | 原生 | 原生 | 原生 | 原生 | 原生 | 不支持 | +| Bundle 后的本地 stdio | 原生 | 原生 | 不支持 | 不支持 | 原生本地进程 | 不支持 | + +远程 MCP 是声明式内容:作者提供 Endpoint 和 Secret 引用。本地 stdio MCP 是可执行内容:作者提供完整 `server.ts`,Extension 通过 Core `portable-node` Compiler profile 只 Bundle 一次 Node 20 ESM,并只复用到具有已验证安装根契约的 Platform。有边界的 initialize/tools-list smoke 会在 development 与 production 中都执行;任何 Contributor 都不会在构建时读取环境变量 Secret 值。 + +## 源码与输出所有权 + +| 关注点 | 事实来源 | +| --- | --- | +| Config、作者类型与 Integration SDK 契约 | `packages/core/src/contracts/`、`api/definitions.ts`、`api/author.ts`、`api/integration.ts` | +| Canonical/Public/Runtime/Extension 发现 | `packages/core/src/resources/` | +| 固定生命周期与 Platform 隔离 | `packages/core/src/lifecycle/build-session.ts` | +| Package、Document、Contribution 与报告 Registry | `packages/core/src/package/` | +| 事务化输出 | `packages/core/src/output/transaction.ts` | +| Platform 输出契约 | `packages/platforms//src/` | +| Hooks 发现、编译与 Platform Contributor | `packages/extensions/hooks/src/` | +| MCP 发现、编译与 Platform Contributor | `packages/extensions/mcp/src/` | +| 公开 façade、SDK 与 Project 配置加载 | `packages/acplugin/src/index.ts`、`sdk.ts`、`author/project.ts` | +| CLI 与隔离 Migration 边界 | `packages/acplugin/src/cli.ts`、`cli/`、`migration/` | + +Platform 拥有 base Package 路径、Document、Manifest、Schema、最终 Package 身份、Distribution 与候选校验。所有 Extension Contributor 读取同一个不可变 base Package,只能增加自有 Asset、填写已声明的 add-only Document extension point 并报告兼容性;不能观察其他 Contribution、替换 Platform 或直接写入 `dist`。 + +官方契约最后核验于 2026-08-06,来源包括 [Claude Code Hooks](https://code.claude.com/docs/en/hooks)、[Codex Hooks](https://learn.chatgpt.com/docs/hooks)、[Cursor Plugin Schema](https://github.com/cursor/plugins/blob/main/schemas/plugin.schema.json)、[Antigravity Plugins](https://antigravity.google/docs/plugins?app=cli)、[OpenCode Plugins](https://opencode.ai/docs/plugins/)、[OpenCode MCP](https://opencode.ai/docs/mcp-servers/) 和 [Pi Packages](https://pi.dev/docs/latest/packages)。 diff --git a/llmdoc/reference/domain-glossary.md b/llmdoc/reference/domain-glossary.md new file mode 100644 index 0000000..7a79ab7 --- /dev/null +++ b/llmdoc/reference/domain-glossary.md @@ -0,0 +1,26 @@ +# Domain glossary + +> [中文对照](domain-glossary.zh-CN.md) + +| Term | Meaning | +| --- | --- | +| Author facade | The root `@tokenroll/acplugin` API used by project configuration, Project execution, reports, init, and Migration. | +| Integration SDK | The trusted `@tokenroll/acplugin/sdk` subpath used only to implement Platforms and Extensions. | +| Canonical Project | The immutable Commands, Skills, Agents, Public files, optional Runtime, metadata, and validated dependency graph discovered by Core. | +| Session | Per-build mutable implementation state created by a Platform or Extension factory and closed exactly once by Core. | +| SourceRef | An owner-scoped reference to an exact validated author file or directory; it is not a physical path capability. | +| AssetRef | A current-session Source, Generated, or Bytes output reference signed by Core. | +| Asset | A Package path mapped to an AssetRef. Reports add owner, mode, size, SHA-256, and structured origin. | +| Document | A Platform-owned structured JSON, YAML, TOML, or frontmatter value serialized by the Core codec. | +| Extension point | An exact empty Document field path that the Platform explicitly allows one Contribution to fill. | +| Base Package | The immutable Documents, Assets, compatibility, and metadata dispositions returned by `Platform.createPackage()`. | +| Platform Contributor | An Extension callback that reads the same base Package and returns one independent add-only Package Contribution. | +| Package Contribution | Optional Document fields, Assets, subject-bound opaque Platform Components, and required compatibility entries. It cannot replace or delete base content. | +| Platform Component Contribution | A Platform-owned JSON payload submitted by an Extension Contributor. It is not a Canonical Component; Core transports it while the target Platform validates, renders, and owns native output. | +| Merged Package | Core's deterministic result after validating and combining Framework and Extension Contributions with the base Package. | +| Primary Package | The finalized installable Plugin, workspace, or npm package, including all inherited merged Assets. | +| Distribution | An optional Package derived only after the primary Package candidate has passed Platform validation. | +| Package candidate | A Core-owned temporary materialization of the exact Package tree passed to `validatePackage()`. | +| Compatibility | A Platform/resource/capability tuple reported as `native`, `transform`, `degraded`, or `unsupported`. | +| BuildReport | The stable schema-v3 result containing Project, Package, Asset provenance, compatibility, metadata, Platform status, and diagnostics. | +| Managed output | The complete configured output root replaced transactionally for the selected Platform set. | diff --git a/llmdoc/reference/domain-glossary.zh-CN.md b/llmdoc/reference/domain-glossary.zh-CN.md new file mode 100644 index 0000000..8efa238 --- /dev/null +++ b/llmdoc/reference/domain-glossary.zh-CN.md @@ -0,0 +1,26 @@ +# 领域术语表 + +> [English](domain-glossary.md) + +| 术语 | 含义 | +| --- | --- | +| 作者 Facade | 工程配置、Project 执行、报告、init 和 Migration 使用的根 `@tokenroll/acplugin` API。 | +| 集成 SDK | 只用于实现 Platform/Extension 的可信 `@tokenroll/acplugin/sdk` 子路径。 | +| Canonical Project | Core 发现并冻结的 Commands、Skills、Agents、Public、可选 Runtime、metadata 和已验证依赖图。 | +| Session | Platform/Extension factory 为一次 build 创建的可变实现状态,由 Core 恰好关闭一次。 | +| SourceRef | 指向精确已验证作者文件/目录的 owner-scoped 引用;它不是物理路径能力。 | +| AssetRef | Core 为当前 Session 签发的 Source、Generated 或 Bytes 输出引用。 | +| Asset | Package 路径到 AssetRef 的映射;报告补充 owner、mode、size、SHA-256 和结构化 origin。 | +| Document | Platform 拥有的结构化 JSON/YAML/TOML/frontmatter 值,由 Core codec 序列化。 | +| Extension point | Platform 显式允许一个 Contribution 填写的精确空 Document 字段路径。 | +| Base Package | `Platform.createPackage()` 返回的不可变 Document、Asset、compatibility 和 metadata disposition。 | +| Platform Contributor | Extension 回调;读取同一份 base Package,并返回独立的 add-only Package Contribution。 | +| Package Contribution | 可选 Document 字段、Asset、subject-bound 不透明 Platform Component 和必需 compatibility;不能替换或删除 base 内容。 | +| Platform Component Contribution | Extension Contributor 提交的、由 Platform 拥有的 JSON payload。它不是 Canonical Component;Core 只传输它,目标 Platform 负责校验、渲染并拥有原生输出。 | +| Merged Package | Core 验证并把 Framework/Extension Contribution 与 base Package 合并后的确定性结果。 | +| Primary Package | 最终可安装 Plugin、workspace 或 npm package,自动继承全部 merged Asset。 | +| Distribution | 只有 primary Package candidate 通过 Platform 校验后才能派生的可选 Package。 | +| Package candidate | Core 在临时根物化并传给 `validatePackage()` 的精确 Package 文件树。 | +| Compatibility | Platform/resource/capability tuple,等级为 `native`、`transform`、`degraded` 或 `unsupported`。 | +| BuildReport | 稳定 schema-v3 结果,包含 Project、Package、Asset provenance、兼容性、metadata、Platform 状态和诊断。 | +| 托管输出 | 针对选中 Platform 集合进行事务整体替换的配置输出根。 | diff --git a/llmdoc/startup.md b/llmdoc/startup.md new file mode 100644 index 0000000..84c756e --- /dev/null +++ b/llmdoc/startup.md @@ -0,0 +1,15 @@ +# Startup + +Read [Project overview](overview/project.md) and [System architecture](architecture/system.md) before changing runtime behavior. + +Keep these invariants: + +- pnpm monorepo without Turbo; Node.js >=20; ESM-only. +- `@tokenroll/acplugin`, six official Platform packages, and the official Hooks/MCP Extensions are public and independently versioned; Core, test, Docs, and Playground are private. Node Runtime is built into Core. +- Core owns one lifecycle and transaction; Extensions join through unordered add-only Contributors, while Platforms own Package schemas and distributions. +- Commands, Skills, and Agents are Core Components. Instructions are out of scope. +- `platforms` is required and contains explicitly imported package instances. Only `init` selects Claude Code and Codex when no scaffold option is supplied. +- Migration stays lazy and isolated under `packages/acplugin/src/migration/`; legacy code is not normal runtime architecture. +- Preserve deterministic, strict, whole-output builds. Official integrations import only the public main-package SDK through peer dependencies, and no public package exposes a private `@acplugin/*` runtime dependency. + +Use `pnpm run check` for runtime repository validation and `pnpm run docs:check` for Docs/Playground validation. PR automation intentionally runs only separate lint and typecheck Actions; run behavior, Docs, Playground, and packed-consumer checks in proportion to the change. diff --git a/llmdoc/state/sync.md b/llmdoc/state/sync.md new file mode 100644 index 0000000..086e333 --- /dev/null +++ b/llmdoc/state/sync.md @@ -0,0 +1,40 @@ +# llmdoc sync state + +- Rewrite fixed point: `889da323a73d0016870390be32f82cc0c79c6a00` +- Current integrated commit: `51d68522ba6044fb60d06573491d4504688ddf56` +- Workspace state: the post-layout remediation is unstaged, with the three relocation destinations still reported as untracked and their former paths as unstaged deletions. `review1.md` and `review2.md` remain the user's pre-existing staged review inputs. The worktree is intentionally not clean. +- Mode: `full` +- Workflow: the architecture rewrite and the follow-up package-layout reorganization are integrated at the current commit. The post-layout review remediation tickets T01–T05 are complete, have passed full validation, and have passed final Standards/Spec review with no remaining finding. No commit, push, publication, Tag, Release, dist-tag, or registry mutation was performed. +- Architecture: ACPlugin is a Rolldown-based AI Plugin framework and CLI. Core owns the only lifecycle, Module/Compiler/Execution/Watch services, Source/Asset authorization, Package merge, candidate materialization, compatibility report, transaction, and DevSession. `LIFECYCLE_API_VERSION` remains `1` by explicit product decision; no v1 compatibility layer remains. +- Public packages: nine independently versioned packages comprise the main package, six Platform packages, and Hooks/MCP Extensions. Core, Test, Docs, and Playground are private. Node Runtime is a Core Framework Resource, not an Extension package. +- Public boundaries: authors use the root `@tokenroll/acplugin` entry and only `defineConfig()` as a define helper. Platform and Extension implementations use `@tokenroll/acplugin/sdk`. The main package bundles private Core but does not bundle or re-export official integrations; public tarballs have no private `@acplugin/*` runtime dependency. +- Project lifecycle: configuration creates a Project; each build creates isolated Platform/Extension Sessions, discovers Resources, assembles one immutable CanonicalProject, builds Extensions and Framework Runtime, creates Platform base Packages, collects unordered Contributions against the same base snapshot, performs one Core add-only merge, finalizes and validates Package candidates and Distributions, commits the whole selected output transaction, then closes Sessions once in reverse initialization order. +- Contributor semantics: Extensions have no dependency graph, ordering API, cross-Extension state, claim, suppress, override, or delete authority. Conflicting fields and Package Asset paths fail deterministically regardless of configuration order. +- Core build services: `ModuleHost` loads trusted TS/JS config and descriptors through the Core Rolldown driver. `CompilerHost` provides `portable-node` and bounded `managed-rolldown` profiles; cwd, input identity, output, logs, watch, close, physical work directories, Asset signing, deterministic audits, and license policy remain Core-owned. +- Portable policy: `portable-node` emits deterministic self-contained Node 20 ESM single chunks, preserves only real `node:` builtins, rejects unresolved imports, native addons, author symlinks/special files, unsafe source/output paths, and missing third-party legal material, and records the actual module/license graph for watch. +- Managed policy: `managed-rolldown` supports file and Plugin-virtual inputs, multi-output/code splitting, bounded Rolldown Plugins/options, explicit tsconfig, and deterministic/unresolved/native audits. `licenses: 'strict'` is the default; explicit `ignore` transfers only legal-material responsibility and does not bypass source or output controls. +- Node Runtime: direct supported TS/JS files under `src/runtime/` become executable entries by convention. Explicit `runtime.entries` replaces discovery and may select executable/module mode; `runtime: false` disables it. Core builds each entry once, and only Platforms declaring the exact Plugin-local Node 20 ESM capability inherit the same Assets. Unsupported Platforms report compatibility and emit no pseudo Runtime. +- Extension packaging: Hooks and MCP contain no independent bundler, module-graph collector, watcher, or license writer. Descriptor modules use plain default exports and safe frozen snapshots. Local code uses Core `portable-node`; Hook runner and MCP smoke use bounded Core Execution Service. +- Platform packaging: each Platform owns canonical Component conversion, Documents, base/final Package shape, optional Distributions, target candidate validation, compatibility, and metadata disposition. Platform failures are isolated and diagnosed by the precise `package`, `contribute`, `finalize`, `materialize`, or `platform-validate` stage. +- MCP correctness: six Platform Contributors implement target-specific transport/auth contracts. OpenCode uses its canonical local/remote MCP schema without an unproven sidecar. Compatibility reflects lost auth semantics, and stable ordering is locale-independent. +- Dev: `Project.dev()` delegates to the Core-owned DevSession. It permits one active BuildSession round, coalesces pending changes, runs catch-up rounds, reconciles current module/source/license graphs, preserves the last successful output after failures, and drains safely on idempotent close or process signals. No CLI or Integration watcher path exists. +- Reports and output: BuildReport schema v3 describes Components, Runtimes, Extensions, Platforms, Packages, Assets with structured origin, compatibility, metadata dispositions, and stage diagnostics. Component-contribution-driven generated Assets and Documents record only stable contributor owner/subject provenance. Stable output excludes bytes, timestamps, environment values, credentials, absolute project paths, and temporary roots. The selected Platform set is committed by one recoverable whole-output transaction. +- Playground/docs/release: the domain-neutral Playground exercises all Components, Hook events, HTTP/stdio MCP, Core Runtime, Public files, six Platforms, supported Distributions, deterministic bytes, and real protocol execution. VitePress and TypeDoc cover author and `/sdk` APIs. Changesets independently version the nine public packages; Changelog maintains version PRs after `main` merges, stable Release is manually dispatched, and beta publication remains a local pnpm command. +- Migration: legacy input support stays lazily isolated under `packages/acplugin/src/migration/`. It uses the real Project/Kernel lifecycle for generated-project validation but is not a second normal build path; tolerant legacy readers are not mechanically rewritten to match strict Core internals. +- Cleanup: obsolete Scanner/config/lifecycle/Artifact/DeliveryUnit implementations, Integration-local bundlers, adapter-era files, `output-paths.ts`, Core `kernel/`, transitional root modules, old Platform root package implementations, monolithic Extension tests, and stale test-name suffixes are removed. Core source and tests now mirror explicit contract, compiler, resource, lifecycle, package, output, service, and security domains. Architecture guards allow no v1 production symbol, confine Rolldown to the Core driver, and confine Chokidar to Core DevSession. +- Release state: the architecture Changeset remains in beta prerelease state; manifests and generated changelogs target main `0.0.2-beta`, Codex `0.0.3-beta`, and the other five Platforms plus Hooks/MCP `0.0.2-beta`. `publish:beta:dry-run` and `publish:beta` use the same recursive public-workspace pnpm flow after the root build; `Release` only accepts stable semver versions and is manually dispatched. +- Validation: the baseline before this workflow simplification passed lint, TypeScript 7 typecheck, all Vitest tests, tsdown build, ATTW, publint, TypeDoc, VitePress, real Playground verification, and release tarball clean-consumer verification. Versions check succeeds and Changeset status lists exactly the nine public packages. Static audits find no v1 production symbol or deleted legacy file, keep direct Rolldown ownership in `compiler/engine-loader.ts`, keep Chokidar ownership in `lifecycle/dev-session.ts`, find no Integration `dist` write, find no cycle in the 70-file Core runtime import graph, and both staged and unstaged Git whitespace checks pass. +- Prior review state: the architecture/package-layout review found one stale repository-skill path set and five transitional test names; both findings were fixed and independently rechecked before the current post-layout remediation. +- Current review state: the post-layout Standards/Spec review findings were remediated and independently re-reviewed; both axes pass with no remaining finding. + +```text +pnpm install --frozen-lockfile +pnpm run lint +pnpm run typecheck +pnpm run test +pnpm run build +pnpm run docs:check +pnpm changeset status +git diff --check +git diff --cached --check +``` diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index ad929a7..0000000 --- a/package-lock.json +++ /dev/null @@ -1,2628 +0,0 @@ -{ - "name": "@disdjj/acplugin", - "version": "1.6.1", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@disdjj/acplugin", - "version": "1.6.1", - "license": "MIT", - "dependencies": { - "@iarna/toml": "^2.2.5", - "@inquirer/prompts": "^8.3.2", - "chalk": "^4.1.2", - "commander": "^14.0.3", - "glob": "^13.0.6", - "gray-matter": "^4.0.3", - "ora": "^5.4.1", - "typescript": "^5.9.3" - }, - "bin": { - "acplugin": "dist/index.js" - }, - "devDependencies": { - "@types/node": "^25.5.0", - "vitest": "^3.2.1" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", - "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", - "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", - "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", - "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", - "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", - "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", - "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", - "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", - "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", - "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", - "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", - "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", - "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", - "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", - "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", - "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", - "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", - "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", - "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", - "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", - "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", - "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", - "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", - "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", - "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", - "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@iarna/toml": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz", - "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==", - "license": "ISC" - }, - "node_modules/@inquirer/ansi": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.4.tgz", - "integrity": "sha512-DpcZrQObd7S0R/U3bFdkcT5ebRwbTTC4D3tCc1vsJizmgPLxNJBo+AAFmrZwe8zk30P2QzgzGWZ3Q9uJwWuhIg==", - "license": "MIT", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - } - }, - "node_modules/@inquirer/checkbox": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.1.2.tgz", - "integrity": "sha512-PubpMPO2nJgMufkoB3P2wwxNXEMUXnBIKi/ACzDUYfaoPuM7gSTmuxJeMscoLVEsR4qqrCMf5p0SiYGWnVJ8kw==", - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.4", - "@inquirer/core": "^11.1.7", - "@inquirer/figures": "^2.0.4", - "@inquirer/type": "^4.0.4" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/confirm": { - "version": "6.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.0.10.tgz", - "integrity": "sha512-tiNyA73pgpQ0FQ7axqtoLUe4GDYjNCDcVsbgcA5anvwg2z6i+suEngLKKJrWKJolT//GFPZHwN30binDIHgSgQ==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.1.7", - "@inquirer/type": "^4.0.4" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/core": { - "version": "11.1.7", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.7.tgz", - "integrity": "sha512-1BiBNDk9btIwYIzNZpkikIHXWeNzNncJePPqwDyVMhXhD1ebqbpn1mKGctpoqAbzywZfdG0O4tvmsGIcOevAPQ==", - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.4", - "@inquirer/figures": "^2.0.4", - "@inquirer/type": "^4.0.4", - "cli-width": "^4.1.0", - "fast-wrap-ansi": "^0.2.0", - "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/editor": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.0.10.tgz", - "integrity": "sha512-VJx4XyaKea7t8hEApTw5dxeIyMtWXre2OiyJcICCRZI4hkoHsMoCnl/KbUnJJExLbH9csLLHMVR144ZhFE1CwA==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.1.7", - "@inquirer/external-editor": "^2.0.4", - "@inquirer/type": "^4.0.4" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/expand": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.0.10.tgz", - "integrity": "sha512-fC0UHJPXsTRvY2fObiwuQYaAnHrp3aDqfwKUJSdfpgv18QUG054ezGbaRNStk/BKD5IPijeMKWej8VV8O5Q/eQ==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.1.7", - "@inquirer/type": "^4.0.4" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/external-editor": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-2.0.4.tgz", - "integrity": "sha512-Prenuv9C1PHj2Itx0BcAOVBTonz02Hc2Nd2DbU67PdGUaqn0nPCnV34oDyyoaZHnmfRxkpuhh/u51ThkrO+RdA==", - "license": "MIT", - "dependencies": { - "chardet": "^2.1.1", - "iconv-lite": "^0.7.2" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/figures": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.4.tgz", - "integrity": "sha512-eLBsjlS7rPS3WEhmOmh1znQ5IsQrxWzxWDxO51e4urv+iVrSnIHbq4zqJIOiyNdYLa+BVjwOtdetcQx1lWPpiQ==", - "license": "MIT", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - } - }, - "node_modules/@inquirer/input": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.0.10.tgz", - "integrity": "sha512-nvZ6qEVeX/zVtZ1dY2hTGDQpVGD3R7MYPLODPgKO8Y+RAqxkrP3i/3NwF3fZpLdaMiNuK0z2NaYIx9tPwiSegQ==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.1.7", - "@inquirer/type": "^4.0.4" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/number": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.0.10.tgz", - "integrity": "sha512-Ht8OQstxiS3APMGjHV0aYAjRAysidWdwurWEo2i8yI5xbhOBWqizT0+MU1S2GCcuhIBg+3SgWVjEoXgfhY+XaA==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.1.7", - "@inquirer/type": "^4.0.4" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/password": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.0.10.tgz", - "integrity": "sha512-QbNyvIE8q2GTqKLYSsA8ATG+eETo+m31DSR0+AU7x3d2FhaTWzqQek80dj3JGTo743kQc6mhBR0erMjYw5jQ0A==", - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.4", - "@inquirer/core": "^11.1.7", - "@inquirer/type": "^4.0.4" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/prompts": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.3.2.tgz", - "integrity": "sha512-yFroiSj2iiBFlm59amdTvAcQFvWS6ph5oKESls/uqPBect7rTU2GbjyZO2DqxMGuIwVA8z0P4K6ViPcd/cp+0w==", - "license": "MIT", - "dependencies": { - "@inquirer/checkbox": "^5.1.2", - "@inquirer/confirm": "^6.0.10", - "@inquirer/editor": "^5.0.10", - "@inquirer/expand": "^5.0.10", - "@inquirer/input": "^5.0.10", - "@inquirer/number": "^4.0.10", - "@inquirer/password": "^5.0.10", - "@inquirer/rawlist": "^5.2.6", - "@inquirer/search": "^4.1.6", - "@inquirer/select": "^5.1.2" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/rawlist": { - "version": "5.2.6", - "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.2.6.tgz", - "integrity": "sha512-jfw0MLJ5TilNsa9zlJ6nmRM0ZFVZhhTICt4/6CU2Dv1ndY7l3sqqo1gIYZyMMDw0LvE1u1nzJNisfHEhJIxq5w==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.1.7", - "@inquirer/type": "^4.0.4" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/search": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.1.6.tgz", - "integrity": "sha512-3/6kTRae98hhDevENScy7cdFEuURnSpM3JbBNg8yfXLw88HgTOl+neUuy/l9W0No5NzGsLVydhBzTIxZP7yChQ==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.1.7", - "@inquirer/figures": "^2.0.4", - "@inquirer/type": "^4.0.4" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/select": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.1.2.tgz", - "integrity": "sha512-kTK8YIkHV+f02y7bWCh7E0u2/11lul5WepVTclr3UMBtBr05PgcZNWfMa7FY57ihpQFQH/spLMHTcr0rXy50tA==", - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.4", - "@inquirer/core": "^11.1.7", - "@inquirer/figures": "^2.0.4", - "@inquirer/type": "^4.0.4" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/type": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.4.tgz", - "integrity": "sha512-PamArxO3cFJZoOzspzo6cxVlLeIftyBsZw/S9bKY5DzxqJVZgjoj1oP8d0rskKtp7sZxBycsoer1g6UeJV1BBA==", - "license": "MIT", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.1.tgz", - "integrity": "sha512-xB0b51TB7IfDEzAojXahmr+gfA00uYVInJGgNNkeQG6RPnCPGr7udsylFLTubuIUSRE6FkcI1NElyRt83PP5oQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.1.tgz", - "integrity": "sha512-XOjPId0qwSDKHaIsdzHJtKCxX0+nH8MhBwvrNsT7tVyKmdTx1jJ4XzN5RZXCdTzMpufLb+B8llTC0D8uCrLhcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.1.tgz", - "integrity": "sha512-vQuRd28p0gQpPrS6kppd8IrWmFo42U8Pz1XLRjSZXq5zCqyMDYFABT7/sywL11mO1EL10Qhh7MVPEwkG8GiBeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.1.tgz", - "integrity": "sha512-x6VG6U29+Ivlnajrg1IHdzXeAwSoEHBFVO+CtC9Brugx6de712CUJobRUxsIA0KYrQvCmzNrMPFTT1A4CCqNTg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.1.tgz", - "integrity": "sha512-Sgi0Uo6t1YCHJMNO3Y8+bm+SvOanUGkoZKn/VJPwYUe2kp31X5KnXmzKd/NjW8iA3gFcfNZ64zh14uOGrIllCQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.1.tgz", - "integrity": "sha512-AM4xnwEZwukdhk7laMWfzWu9JGSVnJd+Fowt6Fd7QW1nrf3h0Hp7Qx5881M4aqrUlKBCybOxz0jofvIIfl7C5g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.1.tgz", - "integrity": "sha512-KUizqxpwaR2AZdAUsMWfL/C94pUu7TKpoPd88c8yFVixJ+l9hejkrwoK5Zj3wiNh65UeyryKnJyxL1b7yNqFQA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.1.tgz", - "integrity": "sha512-MZoQ/am77ckJtZGFAtPucgUuJWiop3m2R3lw7tC0QCcbfl4DRhQUBUkHWCkcrT3pqy5Mzv5QQgY6Dmlba6iTWg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.1.tgz", - "integrity": "sha512-Sez95TP6xGjkWB1608EfhCX1gdGrO5wzyN99VqzRtC17x/1bhw5VU1V0GfKUwbW/Xr1J8mSasoFoJa6Y7aGGSA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.1.tgz", - "integrity": "sha512-9Cs2Seq98LWNOJzR89EGTZoiP8EkZ9UbQhBlDgfAkM6asVna1xJ04W2CLYWDN/RpUgOjtQvcv8wQVi1t5oQazA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.1.tgz", - "integrity": "sha512-n9yqttftgFy7IrNEnHy1bOp6B4OSe8mJDiPkT7EqlM9FnKOwUMnCK62ixW0Kd9Clw0/wgvh8+SqaDXMFvw3KqQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.1.tgz", - "integrity": "sha512-SfpNXDzVTqs/riak4xXcLpq5gIQWsqGWMhN1AGRQKB4qGSs4r0sEs3ervXPcE1O9RsQ5bm8Muz6zmQpQnPss1g==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.1.tgz", - "integrity": "sha512-LjaChED0wQnjKZU+tsmGbN+9nN1XhaWUkAlSbTdhpEseCS4a15f/Q8xC2BN4GDKRzhhLZpYtJBZr2NZhR0jvNw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.1.tgz", - "integrity": "sha512-ojW7iTJSIs4pwB2xV6QXGwNyDctvXOivYllttuPbXguuKDX5vwpqYJsHc6D2LZzjDGHML414Tuj3LvVPe1CT1A==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.1.tgz", - "integrity": "sha512-FP+Q6WTcxxvsr0wQczhSE+tOZvFPV8A/mUE6mhZYFW9/eea/y/XqAgRoLLMuE9Cz0hfX5bi7p116IWoB+P237A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.1.tgz", - "integrity": "sha512-L1uD9b/Ig8Z+rn1KttCJjwhN1FgjRMBKsPaBsDKkfUl7GfFq71pU4vWCnpOsGljycFEbkHWARZLf4lMYg3WOLw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.1.tgz", - "integrity": "sha512-EZc9NGTk/oSUzzOD4nYY4gIjteo2M3CiozX6t1IXGCOdgxJTlVu/7EdPeiqeHPSIrxkLhavqpBAUCfvC6vBOug==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.1.tgz", - "integrity": "sha512-NQ9KyU1Anuy59L8+HHOKM++CoUxrQWrZWXRik4BJFm+7i5NP6q/SW43xIBr80zzt+PDBJ7LeNmloQGfa0JGk0w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.1.tgz", - "integrity": "sha512-GZkLk2t6naywsveSFBsEb0PLU+JC9ggVjbndsbG20VPhar6D1gkMfCx4NfP9owpovBXTN+eRdqGSkDGIxPHhmQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.1.tgz", - "integrity": "sha512-1hjG9Jpl2KDOetr64iQd8AZAEjkDUUK5RbDkYWsViYLC1op1oNzdjMJeFiofcGhqbNTaY2kfgqowE7DILifsrA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.1.tgz", - "integrity": "sha512-ARoKfflk0SiiYm3r1fmF73K/yB+PThmOwfWCk1sr7x/k9dc3uGLWuEE9if+Pw21el8MSpp3TMnG5vLNsJ/MMGQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.1.tgz", - "integrity": "sha512-oOST61G6VM45Mz2vdzWMr1s2slI7y9LqxEV5fCoWi2MDONmMvgsJVHSXxce/I2xOSZPTZ47nDPOl1tkwKWSHcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.1.tgz", - "integrity": "sha512-x5WgLi5dWpRz7WclKBGEF15LcWTh0ewrHM6Cq4A+WUbkysUMZNeqt05bwPonOQ3ihPS/WMhAZV5zB1DfnI4Sxg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.1.tgz", - "integrity": "sha512-wS+zHAJRVP5zOL0e+a3V3E/NTEwM2HEvvNKoDy5Xcfs0o8lljxn+EAFPkUsxihBdmDq1JWzXmmB9cbssCPdxxw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.1.tgz", - "integrity": "sha512-rhHyrMeLpErT/C7BxcEsU4COHQUzHyrPYW5tOZUeUhziNtRuYxmDWvqQqzpuUt8xpOgmbKa1btGXfnA/ANVO+g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "25.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", - "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.2.4", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "3.2.4", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "magic-string": "^0.30.17", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chardet": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", - "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", - "license": "MIT" - }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "license": "MIT", - "dependencies": { - "restore-cursor": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "license": "ISC", - "engines": { - "node": ">= 12" - } - }, - "node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "license": "MIT", - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", - "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.4", - "@esbuild/android-arm": "0.27.4", - "@esbuild/android-arm64": "0.27.4", - "@esbuild/android-x64": "0.27.4", - "@esbuild/darwin-arm64": "0.27.4", - "@esbuild/darwin-x64": "0.27.4", - "@esbuild/freebsd-arm64": "0.27.4", - "@esbuild/freebsd-x64": "0.27.4", - "@esbuild/linux-arm": "0.27.4", - "@esbuild/linux-arm64": "0.27.4", - "@esbuild/linux-ia32": "0.27.4", - "@esbuild/linux-loong64": "0.27.4", - "@esbuild/linux-mips64el": "0.27.4", - "@esbuild/linux-ppc64": "0.27.4", - "@esbuild/linux-riscv64": "0.27.4", - "@esbuild/linux-s390x": "0.27.4", - "@esbuild/linux-x64": "0.27.4", - "@esbuild/netbsd-arm64": "0.27.4", - "@esbuild/netbsd-x64": "0.27.4", - "@esbuild/openbsd-arm64": "0.27.4", - "@esbuild/openbsd-x64": "0.27.4", - "@esbuild/openharmony-arm64": "0.27.4", - "@esbuild/sunos-x64": "0.27.4", - "@esbuild/win32-arm64": "0.27.4", - "@esbuild/win32-ia32": "0.27.4", - "@esbuild/win32-x64": "0.27.4" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", - "license": "MIT", - "dependencies": { - "is-extendable": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/fast-string-truncated-width": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", - "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", - "license": "MIT" - }, - "node_modules/fast-string-width": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", - "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", - "license": "MIT", - "dependencies": { - "fast-string-truncated-width": "^3.0.2" - } - }, - "node_modules/fast-wrap-ansi": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.0.tgz", - "integrity": "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==", - "license": "MIT", - "dependencies": { - "fast-string-width": "^3.0.2" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "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/gray-matter": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", - "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", - "license": "MIT", - "dependencies": { - "js-yaml": "^3.13.1", - "kind-of": "^6.0.2", - "section-matter": "^1.0.0", - "strip-bom-string": "^1.0.0" - }, - "engines": { - "node": ">=6.0" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/is-extendable": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "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==", - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/mute-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", - "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "license": "MIT", - "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "license": "MIT", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/restore-cursor/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" - }, - "node_modules/rollup": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.1.tgz", - "integrity": "sha512-iZKH8BeoCwTCBTZBZWQQMreekd4mdomwdjIQ40GC1oZm6o+8PnNMIxFOiCsGMWeS8iDJ7KZcl7KwmKk/0HOQpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.1", - "@rollup/rollup-android-arm64": "4.59.1", - "@rollup/rollup-darwin-arm64": "4.59.1", - "@rollup/rollup-darwin-x64": "4.59.1", - "@rollup/rollup-freebsd-arm64": "4.59.1", - "@rollup/rollup-freebsd-x64": "4.59.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.1", - "@rollup/rollup-linux-arm-musleabihf": "4.59.1", - "@rollup/rollup-linux-arm64-gnu": "4.59.1", - "@rollup/rollup-linux-arm64-musl": "4.59.1", - "@rollup/rollup-linux-loong64-gnu": "4.59.1", - "@rollup/rollup-linux-loong64-musl": "4.59.1", - "@rollup/rollup-linux-ppc64-gnu": "4.59.1", - "@rollup/rollup-linux-ppc64-musl": "4.59.1", - "@rollup/rollup-linux-riscv64-gnu": "4.59.1", - "@rollup/rollup-linux-riscv64-musl": "4.59.1", - "@rollup/rollup-linux-s390x-gnu": "4.59.1", - "@rollup/rollup-linux-x64-gnu": "4.59.1", - "@rollup/rollup-linux-x64-musl": "4.59.1", - "@rollup/rollup-openbsd-x64": "4.59.1", - "@rollup/rollup-openharmony-arm64": "4.59.1", - "@rollup/rollup-win32-arm64-msvc": "4.59.1", - "@rollup/rollup-win32-ia32-msvc": "4.59.1", - "@rollup/rollup-win32-x64-gnu": "4.59.1", - "@rollup/rollup-win32-x64-msvc": "4.59.1", - "fsevents": "~2.3.2" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/section-matter": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", - "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", - "license": "MIT", - "dependencies": { - "extend-shallow": "^2.0.1", - "kind-of": "^6.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "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/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "license": "BSD-3-Clause" - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-bom-string": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", - "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", - "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/debug": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/wcwidth": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", - "license": "MIT", - "dependencies": { - "defaults": "^1.0.3" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - } - } -} diff --git a/package.json b/package.json index 78b3fe3..8633d84 100644 --- a/package.json +++ b/package.json @@ -1,57 +1,57 @@ { - "name": "@disdjj/acplugin", - "version": "1.6.1", - "description": "Convert Claude Code plugins to Codex, OpenCode, and Cursor formats", - "main": "dist/index.js", - "bin": { - "acplugin": "dist/index.js" + "name": "acplugin-workspace", + "version": "0.0.1-beta", + "private": true, + "type": "module", + "packageManager": "pnpm@10.34.5", + "engines": { + "node": "^22.18.0 || >=24.11.0" }, - "files": [ - "dist/**/*", - "!dist/__tests__", - "README.md" - ], "scripts": { - "build": "tsc", - "start": "node dist/index.js", - "dev": "tsc && node dist/index.js", - "test": "vitest run", - "test:watch": "vitest", - "prepublishOnly": "npm run build" + "build": "pnpm --filter @acplugin/core run build && pnpm --filter \"@tokenroll/acplugin-platform-*\" run build && pnpm --filter \"@tokenroll/acplugin-extension-*\" run build && pnpm --filter @tokenroll/acplugin run build", + "dev": "pnpm --filter @tokenroll/acplugin run dev", + "docs:dev": "pnpm --filter @acplugin/docs run dev", + "docs:build": "pnpm --filter @acplugin/docs run build", + "docs:verify": "node scripts/verify-docs.mjs", + "playground:typecheck": "pnpm --filter @acplugin/playground run typecheck", + "playground:verify": "node scripts/verify-playground.mjs", + "playground:check": "pnpm run build && pnpm run playground:typecheck && pnpm run playground:verify", + "docs:check": "pnpm run docs:build && pnpm run docs:verify && pnpm run playground:check", + "pretest": "pnpm run build", + "test": "pnpm --config.enable-pre-post-scripts=false -r --if-present run test", + "test:watch": "pnpm --filter @acplugin/test run test:watch", + "changeset": "changeset", + "version-packages": "changeset version && pnpm run versions:sync && pnpm install --lockfile-only", + "versions:sync": "node scripts/sync-ecosystem-versions.mjs --write", + "versions:check": "node scripts/sync-ecosystem-versions.mjs --check", + "publish:beta:dry-run": "pnpm run build && pnpm -r --filter '@tokenroll/*' publish --access public --tag beta --registry=https://registry.npmjs.org/ --no-git-checks --ignore-scripts --dry-run", + "publish:beta": "pnpm run build && pnpm -r --filter '@tokenroll/*' publish --access public --tag beta --registry=https://registry.npmjs.org/ --no-git-checks --ignore-scripts", + "release": "pnpm run build && pnpm -r --filter '@tokenroll/*' publish --access public --tag latest --registry=https://registry.npmjs.org/ --no-git-checks --ignore-scripts", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "typecheck": "pnpm -r --if-present run typecheck", + "check": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build", + "prepare": "husky" }, - "keywords": [ - "claude-code", - "codex", - "opencode", - "cursor", - "plugin", - "converter", - "ai-coding", - "skill", - "mcp" - ], - "author": "", - "repository": { - "type": "git", - "url": "https://github.com/TokenRollAI/acplugin.git" - }, - "license": "MIT", - "type": "commonjs", - "publishConfig": { - "access": "public" - }, - "dependencies": { - "@iarna/toml": "^2.2.5", - "@inquirer/prompts": "^8.3.2", - "chalk": "^4.1.2", - "commander": "^14.0.3", - "glob": "^13.0.6", - "gray-matter": "^4.0.3", - "ora": "^5.4.1", - "typescript": "^5.9.3" + "lint-staged": { + "*.{ts,mts,cts,js,mjs,cjs}": "eslint --fix" }, "devDependencies": { - "@types/node": "^25.5.0", - "vitest": "^3.2.1" + "@arethetypeswrong/core": "^0.18.5", + "@changesets/cli": "^2.31.1", + "@eslint/js": "^10.0.1", + "@stylistic/eslint-plugin": "^5.10.0", + "@tokenroll/acplugin-platform-claude-code": "workspace:^", + "@tokenroll/acplugin-extension-mcp": "workspace:^", + "@types/node": "catalog:", + "@typescript/native": "catalog:", + "eslint": "^10.8.0", + "husky": "^9.1.7", + "lint-staged": "^17.2.0", + "publint": "^0.3.23", + "tsdown": "catalog:", + "typescript": "npm:@typescript/typescript6@^6.0.2", + "typescript-eslint": "^8.66.0", + "vitest": "catalog:" } } diff --git a/packages/acplugin/CHANGELOG.md b/packages/acplugin/CHANGELOG.md new file mode 100644 index 0000000..f179770 --- /dev/null +++ b/packages/acplugin/CHANGELOG.md @@ -0,0 +1,21 @@ +# @tokenroll/acplugin + +## 0.0.3-beta + +### Major Changes + +- Add opaque, subject-bound Platform Component Contributions to the trusted Integration SDK. Core now transports strict JSON payloads and records scoped contributor provenance in BuildReport schema version 3 without acquiring Platform-specific Agent or target-format knowledge. + + Claude Code, Cursor, and OpenCode expose and render their own native Agent contribution payloads during Platform finalization. Codex, Antigravity, and Pi explicitly reject non-empty private component contributions rather than silently dropping them or generating fallback Skills. + + Harden `AssetService.fromBytes()` to accept only exact data-object inputs, exact generated-origin fields, and `string | Uint8Array` bytes so third-party Integrations cannot rely on accessor, hidden-field, or array-like coercion. + +## 0.0.2-beta + +### Major Changes + +- 889da32: Replace the beta lifecycle contract with the Kernel v2 author facade and the `@tokenroll/acplugin/sdk` trusted-integration boundary while keeping `LIFECYCLE_API_VERSION` at `1`. + + Core now owns the fixed Platform/Extension session lifecycle, Rolldown-backed Module/Compiler services, capability-scoped Source/Asset/Execution services, Package Contribution merge, Core Node Runtime delivery, schema-v2 reports, DevSession watch coordination, and recoverable whole-output transactions. + + The CLI, project API, scaffolding, Migration validation, documentation, Playground, and packed-consumer verification now use this single architecture. diff --git a/packages/acplugin/LICENSE b/packages/acplugin/LICENSE new file mode 100644 index 0000000..9113de7 --- /dev/null +++ b/packages/acplugin/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 TokenRollAI + +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/packages/acplugin/README.md b/packages/acplugin/README.md new file mode 100644 index 0000000..1ae3a7f --- /dev/null +++ b/packages/acplugin/README.md @@ -0,0 +1,114 @@ +# @tokenroll/acplugin + +Rolldown-based AI Plugin framework, public lifecycle SDK, and CLI. Platform and Extension implementations are independently installed peer packages. + +Requires Node.js `^20.19.0 || ^22.13.0 || >=23.5.0`. + +```bash +pnpm add -D @tokenroll/acplugin \ + @tokenroll/acplugin-platform-claude-code \ + @tokenroll/acplugin-platform-codex +``` + +```ts +// acplugin.config.ts +import { defineConfig } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [claudeCode(), codex()], +}); +``` + +```text +src/ +├── commands/*.md +├── skills/*/SKILL.md +└── agents/*.md +public/ +acplugin.config.ts +``` + +`platforms` is required and there is no runtime default. `init` selects Claude Code and Codex only as a scaffolding default, writing their dependencies and imports explicitly. Select all six during scaffolding with: + +```bash +pnpm exec acplugin init my-plugin --yes \ + --platform claude-code codex cursor antigravity opencode pi +``` + +Or install and configure the independent packages directly: + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import antigravity from '@tokenroll/acplugin-platform-antigravity'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; +import cursor from '@tokenroll/acplugin-platform-cursor'; +import openCode from '@tokenroll/acplugin-platform-opencode'; +import pi from '@tokenroll/acplugin-platform-pi'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [claudeCode(), codex(), cursor(), antigravity(), openCode(), pi()], + build: { strict: false }, +}); +``` + +Claude Code, Codex, Cursor, and Antigravity produce Plugin Packages. OpenCode produces a workspace overlay; Pi produces an npm Package. The compatibility report records native, transformed, degraded, and unsupported behavior before any managed output is committed. + +The main package does not re-export official Platforms or Extensions and has no `platforms/*` subpath. Official and third-party integrations use the same `definePlatform()`, `defineExtension()`, Session, and Contributor contracts from `@tokenroll/acplugin/sdk`, so the framework needs no registry or source change to accept another implementation. + +Claude Code can be configured as an explicit Platform. Omitting `marketplace` builds only the installable Plugin; `marketplace: {}` additionally creates a self-contained single-Plugin Marketplace from the top-level metadata. + +Claude Code 可以作为显式 Platform 配置。省略 `marketplace` 时只构建可安装 Plugin;配置 `marketplace: {}` 时,会从顶层元数据推导并额外生成一个自包含的单 Plugin Marketplace。 + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + author: { name: 'TokenRoll', email: 'maintainers@example.com' }, + platforms: [ + claudeCode({ + marketplace: { + owner: { name: 'TokenRoll' }, + category: 'Developer Tools', + tags: ['workflow'], + }, + }), + ], +}); +``` + +The Claude Code output uses `.claude-plugin/plugin.json`, `commands/`, `skills/`, and `agents/`. Hooks and MCP remain independent Extensions: the Platform only exposes validated `hooks` and `mcpServers` manifest extension points and never imports those Extension packages. + +Claude Code 产物使用 `.claude-plugin/plugin.json`、`commands/`、`skills/` 与 `agents/`。Hooks 和 MCP 仍是独立 Extension:Platform 只提供经过校验的 `hooks` 与 `mcpServers` 清单扩展点,不依赖对应 Extension 包。 + +```bash +pnpm exec acplugin validate +pnpm exec acplugin inspect +pnpm exec acplugin build +``` + +Hooks and MCP are optional official Extensions(Hooks 与 MCP 通过可选的官方 Extension 启用). Their Platform Contributors are included in the Extension packages, while each Platform remains independent of them: + +```bash +pnpm add -D @tokenroll/acplugin-extension-hooks @tokenroll/acplugin-extension-mcp +``` + +Node Runtime is built into Core: direct TypeScript/JavaScript files under `src/runtime/` are compiled once through the Core-owned Rolldown Compiler and delivered only to Platforms that declare a stable Plugin-local Node 20 ESM capability. It does not require another package or factory. + +See the [repository documentation](https://github.com/TokenRollAI/acplugin#readme) for the complete authoring schema, compatibility rules, Migration workflow, and security model. + +## License + +MIT diff --git a/packages/acplugin/package.json b/packages/acplugin/package.json new file mode 100644 index 0000000..dc57069 --- /dev/null +++ b/packages/acplugin/package.json @@ -0,0 +1,43 @@ +{ + "name": "@tokenroll/acplugin", + "version": "0.0.3-beta", + "description": "Canonical AI Plugin framework and CLI.", + "type": "module", + "license": "MIT", + "homepage": "https://github.com/TokenRollAI/acplugin#readme", + "repository": { "type": "git", "url": "git+https://github.com/TokenRollAI/acplugin.git" }, + "bugs": { "url": "https://github.com/TokenRollAI/acplugin/issues" }, + "sideEffects": false, + "engines": { "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, + "bin": { "acplugin": "./dist/cli.mjs" }, + "exports": { + ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" }, + "./sdk": { "types": "./dist/sdk.d.mts", "import": "./dist/sdk.mjs" } + }, + "files": ["dist", "README.md", "LICENSE"], + "publishConfig": { "access": "public" }, + "scripts": { + "build": "tsdown", + "dev": "tsdown --watch", + "pretest": "pnpm --filter @acplugin/core run build", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { + "@inquirer/prompts": "^8.3.2", + "commander": "14.0.1", + "gray-matter": "^4.0.3", + "rolldown": "catalog:", + "semver": "^7.8.5", + "spdx-expression-parse": "^5.0.0" + }, + "devDependencies": { + "@acplugin/core": "workspace:*", + "@types/node": "catalog:", + "@types/semver": "^7.7.1", + "@types/spdx-expression-parse": "^4.0.0", + "tsdown": "catalog:", + "@typescript/native": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/acplugin/src/author/project.ts b/packages/acplugin/src/author/project.ts new file mode 100644 index 0000000..d72ea1f --- /dev/null +++ b/packages/acplugin/src/author/project.ts @@ -0,0 +1,24 @@ +import { + createKernelProject, + ProjectConfigError, + runKernelProject, +} from '@acplugin/core'; +import type { + BuildReport, + CreateProjectOptions, + Project, + RunProjectOptions, +} from '@acplugin/core/author'; +import { ACPLUGIN_VERSION } from '../ecosystem/framework-version.js'; + +export { ProjectConfigError }; + +/** 创建绑定同一工程身份且只执行 Kernel BuildSession 的 Project。 */ +export function createProject(options: CreateProjectOptions = {}): Project { + return createKernelProject(options, ACPLUGIN_VERSION); +} + +/** createProject(...).run(...) 的无逻辑 convenience。 */ +export function runProject(options: RunProjectOptions = {}): Promise { + return runKernelProject(options, ACPLUGIN_VERSION); +} diff --git a/packages/acplugin/src/cli.ts b/packages/acplugin/src/cli.ts new file mode 100644 index 0000000..f14ad8f --- /dev/null +++ b/packages/acplugin/src/cli.ts @@ -0,0 +1,6 @@ +#!/usr/bin/env node + +import { main } from './cli/program.js'; + +// 仅 CLI 入口模块执行 main;库入口不会触发参数解析。 +await main(); diff --git a/packages/acplugin/src/cli/commands/build.ts b/packages/acplugin/src/cli/commands/build.ts new file mode 100644 index 0000000..efe3e3f --- /dev/null +++ b/packages/acplugin/src/cli/commands/build.ts @@ -0,0 +1,9 @@ +import type { Command } from 'commander'; +import { addProjectOptions, type ProjectCliOptions } from '../options.js'; +import { runPipeline } from './pipeline.js'; + +/** 注册 build 命令的薄参数适配。 */ +export function registerBuildCommand(program: Command): void { + addProjectOptions(program.command('build').description('Build and atomically commit selected Platforms'), 'production') + .action((options: ProjectCliOptions) => runPipeline('build', options)); +} diff --git a/packages/acplugin/src/cli/commands/dev.ts b/packages/acplugin/src/cli/commands/dev.ts new file mode 100644 index 0000000..4b11cf7 --- /dev/null +++ b/packages/acplugin/src/cli/commands/dev.ts @@ -0,0 +1,82 @@ +import process from 'node:process'; +import type { Command } from 'commander'; +import { + createProject, + ProjectConfigError, + serializeBuildReport, + type BuildReport, +} from '../../index.js'; +import { exitCodeFor, writeDevProgress, writeFailure, writeReport } from '../output.js'; +import { addProjectOptions, type ProjectCliOptions } from '../options.js'; + +/** 只消费 Core Project DevSession,不在 CLI 维护第二套 Watch 或重建队列。 */ +async function runDev(options: ProjectCliOptions): Promise { + /** Project 固定工程与配置身份,DevSession 独占 Watch 和 BuildSession 调度。 */ + const project = createProject({ + ...(options.config === undefined ? {} : { configFile: options.config }), + }); + /** 成功创建后由 signal 幂等关闭的持续 Session。 */ + let session: Awaited> | undefined; + /** 防止多个终止信号重复处理退出。 */ + let stopping = false; + /** JSON 模式关闭时唯一输出的最近报告。 */ + let current: BuildReport | undefined; + /** 终止处理只请求 Core 关闭,不接管它的内部资源。 */ + const stop = (): void => { + if (stopping) + return; + stopping = true; + process.exitCode = 130; + /** close 可在完成终态后报告 cleanup 失败;signal 路径必须显式观察 rejection。 */ + void session?.close().catch(() => undefined); + }; + process.on('SIGINT', stop); + process.on('SIGTERM', stop); + try { + session = await project.dev({ + mode: options.mode, + ...(options.platform === undefined ? {} : { platforms: options.platform }), + commit: true, + }); + current = session.current; + if (stopping) { + await session.close(); + return; + } + if (!options.json) + writeReport(current, false); + else + writeDevProgress(current); + /** 订阅只负责 presentation,不参与调度、Watch 或事务。 */ + session.subscribe((event) => { + if (event.type !== 'build-complete') + return; + current = event.report; + if (!options.json) + writeReport(event.report, false); + else + writeDevProgress(event.report); + }); + await session.closed; + if (options.json && current !== undefined) + process.stdout.write(serializeBuildReport(current)); + if (!stopping && current !== undefined) + process.exitCode = exitCodeFor(current); + } catch (error) { + if (!stopping) { + /** 配置错误属于项目输入,其余 DevSession 创建异常属于框架内部失败。 */ + const internal = !(error instanceof ProjectConfigError); + writeFailure('dev', error, options.json, internal); + process.exitCode = internal ? 2 : 1; + } + } finally { + process.off('SIGINT', stop); + process.off('SIGTERM', stop); + } +} + +/** 注册 dev 命令并保持 Core DevSession 的唯一所有权。 */ +export function registerDevCommand(program: Command): void { + addProjectOptions(program.command('dev').description('Watch and retain the last successful output'), 'development') + .action((options: ProjectCliOptions) => runDev(options)); +} diff --git a/packages/acplugin/src/cli/commands/init.ts b/packages/acplugin/src/cli/commands/init.ts new file mode 100644 index 0000000..83d72c8 --- /dev/null +++ b/packages/acplugin/src/cli/commands/init.ts @@ -0,0 +1,71 @@ +import process from 'node:process'; +import type { Command } from 'commander'; +import { initializeProject, type InitPlatformId } from '../../index.js'; +import { InitError } from '../../scaffolding/prompts.js'; +import { writeFailure, writeKnownFailure } from '../output.js'; + +/** Commander 解析后的 init 选项。 */ +interface InitCliOptions { + readonly yes?: boolean; + readonly name?: string; + readonly displayName?: string; + readonly description?: string; + readonly platform?: InitPlatformId[]; + readonly hooks?: boolean; + readonly mcp?: boolean; + readonly nodeRuntime?: boolean; + readonly install?: boolean; + readonly json?: boolean; +} + +/** 注册 init 命令及其脚手架 facade 适配。 */ +export function registerInitCommand(program: Command): void { + program.command('init') + .description('Create an opinionated canonical plugin project') + .argument('[directory]', 'New or empty destination directory') + .option('-y, --yes', 'Accept deterministic defaults') + .option('--name ', 'Plugin machine name') + .option('--display-name ', 'Plugin display name') + .option('--description ', 'Plugin description') + .option('--platform ', 'Select one or more configured Platforms') + .option('--hooks', 'Enable the official Hooks Extension') + .option('--mcp', 'Enable the official MCP Extension') + .option('--node-runtime', 'Generate a built-in Node Runtime entry') + .option('--install', 'Run pnpm install after scaffolding') + .option('--json', 'Emit one stable JSON result on stdout') + .action(async (directory: string | undefined, options: InitCliOptions) => { + try { + /** init 参数与交互结果共同生成的脚手架结果。 */ + const result = await initializeProject({ + ...(directory === undefined ? {} : { directory }), + ...(options.yes === undefined ? {} : { yes: options.yes }), + ...(options.name === undefined ? {} : { name: options.name }), + ...(options.displayName === undefined ? {} : { displayName: options.displayName }), + ...(options.description === undefined ? {} : { description: options.description }), + ...(options.platform === undefined ? {} : { platforms: options.platform }), + ...(options.hooks === undefined ? {} : { hooks: options.hooks }), + ...(options.mcp === undefined ? {} : { mcp: options.mcp }), + ...(options.nodeRuntime === undefined ? {} : { nodeRuntime: options.nodeRuntime }), + ...(options.install === undefined ? {} : { install: options.install }), + }); + if (options.json) + process.stdout.write(`${JSON.stringify({ schemaVersion: 2, success: true, ...result }, null, 2)}\n`); + else + process.stdout.write(`Created ${result.directory}\nNext: cd ${result.directory} && pnpm install && pnpm build\n`); + if (options.install && !result.installed) + process.exitCode = 1; + } catch (error) { + if (error instanceof InitError) { + writeKnownFailure('init', [{ + code: 'INIT_INVALID', + severity: 'error', + message: error.message, + phase: 'init', + }], options.json); + } else { + writeFailure('init', error, options.json, false); + } + process.exitCode = 1; + } + }); +} diff --git a/packages/acplugin/src/cli/commands/inspect.ts b/packages/acplugin/src/cli/commands/inspect.ts new file mode 100644 index 0000000..1ff6a4c --- /dev/null +++ b/packages/acplugin/src/cli/commands/inspect.ts @@ -0,0 +1,9 @@ +import type { Command } from 'commander'; +import { addProjectOptions, type ProjectCliOptions } from '../options.js'; +import { runPipeline } from './pipeline.js'; + +/** 注册 inspect 命令的薄参数适配。 */ +export function registerInspectCommand(program: Command): void { + addProjectOptions(program.command('inspect').description('Inspect all selected Platform packages'), 'production') + .action((options: ProjectCliOptions) => runPipeline('inspect', options)); +} diff --git a/packages/acplugin/src/cli/commands/migrate.ts b/packages/acplugin/src/cli/commands/migrate.ts new file mode 100644 index 0000000..3f428ea --- /dev/null +++ b/packages/acplugin/src/cli/commands/migrate.ts @@ -0,0 +1,58 @@ +import process from 'node:process'; +import type { Command } from 'commander'; +import { writeFailure } from '../output.js'; + +/** Commander 解析后的 Migration 选项。 */ +interface MigrateCliOptions { + readonly path?: string; + readonly plugin?: string; + readonly all?: boolean; + readonly name?: string; + readonly description?: string; + readonly dryRun?: boolean; + readonly strict?: boolean; + readonly json?: boolean; +} + +/** 注册隔离 Migration 命令;实现继续只通过动态 import 加载。 */ +export function registerMigrateCommand(program: Command): void { + program.command('migrate') + .description('Migrate a legacy Claude project or plugin into canonical source') + .argument('', 'Local path or supported GitHub source') + .argument('[destination]', 'New destination directory') + .option('-p, --path ', 'Sub-path inside a GitHub repository') + .option('--plugin ', 'Select one marketplace plugin') + .option('--all', 'Migrate all marketplace plugins') + .option('--name ', 'Canonical plugin name for project input') + .option('--description ', 'Canonical plugin description for project input') + .option('--dry-run', 'Generate and validate in temporary storage without committing') + .option('--strict', 'Fail when any resource is degraded or unmapped') + .option('--json', 'Emit one stable JSON report on stdout') + .action(async (source: string, destination: string | undefined, options: MigrateCliOptions) => { + try { + // Migration 动态导入保持在独立 chunk,不进入正常配置与构建启动路径。 + const { migrate } = await import('../../migration/index.js'); + /** 旧工程转换产生的结构化迁移报告。 */ + const report = await migrate({ + source, + ...(destination === undefined ? {} : { destination }), + ...(options.path === undefined ? {} : { subPath: options.path }), + ...(options.plugin === undefined ? {} : { plugin: options.plugin }), + ...(options.all === undefined ? {} : { all: options.all }), + ...(options.name === undefined ? {} : { name: options.name }), + ...(options.description === undefined ? {} : { description: options.description }), + ...(options.dryRun === undefined ? {} : { dryRun: options.dryRun }), + ...(options.strict === undefined ? {} : { strict: options.strict }), + }); + if (options.json) + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + else + process.stdout.write(`Migration ${report.success ? 'succeeded' : 'failed'}: ${report.items.length} resource(s)\n`); + if (!report.success) + process.exitCode = 1; + } catch (error) { + writeFailure('migrate', error, options.json, false); + process.exitCode = 1; + } + }); +} diff --git a/packages/acplugin/src/cli/commands/pipeline.ts b/packages/acplugin/src/cli/commands/pipeline.ts new file mode 100644 index 0000000..d1bea50 --- /dev/null +++ b/packages/acplugin/src/cli/commands/pipeline.ts @@ -0,0 +1,30 @@ +import { + ProjectConfigError, + runProject, +} from '../../index.js'; +import { exitCodeFor, writeFailure, writeReport } from '../output.js'; +import type { ProjectCliOptions } from '../options.js'; + +/** 运行一次 validate、inspect 或 build。 */ +export async function runPipeline( + command: 'validate' | 'inspect' | 'build', + options: ProjectCliOptions, +): Promise { + try { + /** Project facade 与程序化 API 共用的唯一 BuildSession 报告。 */ + const report = await runProject({ + command, + mode: options.mode, + ...(options.config === undefined ? {} : { configFile: options.config }), + ...(options.platform === undefined ? {} : { platforms: options.platform }), + commit: command === 'build', + }); + writeReport(report, options.json); + process.exitCode = exitCodeFor(report); + } catch (error) { + /** 配置错误属于项目输入,其余未预期异常属于框架内部失败。 */ + const internal = !(error instanceof ProjectConfigError); + writeFailure(command, error, options.json, internal); + process.exitCode = internal ? 2 : 1; + } +} diff --git a/packages/acplugin/src/cli/commands/validate.ts b/packages/acplugin/src/cli/commands/validate.ts new file mode 100644 index 0000000..55e3b7f --- /dev/null +++ b/packages/acplugin/src/cli/commands/validate.ts @@ -0,0 +1,9 @@ +import type { Command } from 'commander'; +import { addProjectOptions, type ProjectCliOptions } from '../options.js'; +import { runPipeline } from './pipeline.js'; + +/** 注册 validate 命令的薄参数适配。 */ +export function registerValidateCommand(program: Command): void { + addProjectOptions(program.command('validate').description('Validate all selected Platform packages'), 'production') + .action((options: ProjectCliOptions) => runPipeline('validate', options)); +} diff --git a/packages/acplugin/src/cli/options.ts b/packages/acplugin/src/cli/options.ts new file mode 100644 index 0000000..54d0970 --- /dev/null +++ b/packages/acplugin/src/cli/options.ts @@ -0,0 +1,19 @@ +import { Command, Option } from 'commander'; +import type { BuildMode } from '../index.js'; + +/** validate、inspect、build 和 dev 命令共享的 CLI 选项。 */ +export interface ProjectCliOptions { + readonly config?: string; + readonly platform?: string[]; + readonly mode: BuildMode; + readonly json?: boolean; +} + +/** 为 Project 子命令注册一致且不覆盖配置语义的选项。 */ +export function addProjectOptions(command: Command, defaultMode: BuildMode): Command { + return command + .option('-c, --config ', 'Use another project-relative TypeScript config file') + .addOption(new Option('--platform ', 'Select a subset of configured Platforms')) + .addOption(new Option('--mode ', 'Config mode').choices(['development', 'production']).default(defaultMode)) + .option('--json', 'Emit one stable JSON report on stdout'); +} diff --git a/packages/acplugin/src/cli/output.ts b/packages/acplugin/src/cli/output.ts new file mode 100644 index 0000000..fed5b18 --- /dev/null +++ b/packages/acplugin/src/cli/output.ts @@ -0,0 +1,115 @@ +import process from 'node:process'; +import { + ProjectConfigError, + serializeBuildReport, + type BuildReport, +} from '../index.js'; + +/** CLI 边界失败使用的脱敏诊断。 */ +interface CliFailureDiagnostic { + readonly code: string; + readonly severity: 'error' | 'warning'; + readonly message: string; + readonly phase: string; +} + +/** 尚未产生 BuildReport 时使用的最小 CLI 失败报告。 */ +interface CliFailureReport { + readonly schemaVersion: 2; + readonly command: string; + readonly diagnostics: readonly CliFailureDiagnostic[]; + readonly success: false; +} + +/** 按机器或人类可读模式输出完整 Kernel v2 报告。 */ +export function writeReport(report: BuildReport, json: boolean | undefined): void { + if (json) { + process.stdout.write(serializeBuildReport(report)); + return; + } + /** 普通文本摘要使用的稳定状态词。 */ + const status = report.success ? 'success' : 'failed'; + /** 本次真正选中的 Platform ID。 */ + const selected = report.platforms.filter(platform => platform.selected).map(platform => platform.id); + process.stdout.write(`${report.command}: ${status} (${selected.join(', ')})\n`); + if (report.command === 'inspect') { + for (const component of report.components) + process.stdout.write(`component ${component.kind}/${component.id}\n`); + for (const runtime of report.runtimes) + process.stdout.write(`runtime ${runtime.id} ${runtime.kind} built:${runtime.built}\n`); + for (const extension of report.extensions) + process.stdout.write(`extension ${extension.id} resources:${extension.discovered}\n`); + for (const platform of report.platforms) + process.stdout.write(`platform ${platform.id} selected:${platform.selected} success:${platform.success} packages:${platform.packageIds.join(',')}\n`); + for (const unit of report.packages) { + process.stdout.write(`package ${unit.platform}/${unit.id} ${unit.role}:${unit.type}\n`); + for (const asset of unit.assets) + process.stdout.write(` asset ${asset.path} ${asset.owner} ${asset.mode.toString(8)} ${asset.size} ${asset.sha256}\n`); + } + for (const entry of report.compatibility) + process.stdout.write(`compatibility ${entry.platform} ${entry.subject}/${entry.capability} ${entry.level}: ${entry.reason}\n`); + for (const entry of report.metadata) + process.stdout.write(`metadata ${entry.platform} ${entry.field} ${entry.disposition}: ${entry.reason}\n`); + } + for (const diagnostic of report.diagnostics) + process.stderr.write(`${diagnostic.severity} ${diagnostic.code}: ${diagnostic.message}\n`); + for (const entry of report.compatibility) { + if (entry.level === 'degraded' || entry.level === 'unsupported') + process.stderr.write(`warning ${entry.platform} ${entry.subject}: ${entry.reason}\n`); + } +} + +/** JSON dev 不占用 stdout,只在 stderr 发布可观测轮次摘要。 */ +export function writeDevProgress(report: BuildReport): void { + /** 与人类可读摘要一致的稳定状态词。 */ + const status = report.success ? 'success' : 'failed'; + /** 本轮实际选中的 Platform ID。 */ + const selected = report.platforms.filter(platform => platform.selected).map(platform => platform.id); + process.stderr.write(`${report.command}: ${status} (${selected.join(', ')})\n`); + for (const diagnostic of report.diagnostics) + process.stderr.write(`${diagnostic.severity} ${diagnostic.code}: ${diagnostic.message}\n`); +} + +/** 将配置或命令异常转换为不泄露内部详情的 CLI 报告。 */ +function failureReport(command: string, error: unknown, internal: boolean): CliFailureReport { + /** 配置错误保留安全原因,其余异常只输出固定消息。 */ + const diagnostics: readonly CliFailureDiagnostic[] = error instanceof ProjectConfigError + ? error.diagnostics + : [{ + code: internal ? 'FRAMEWORK_INTERNAL_FAILED' : 'COMMAND_FAILED', + severity: 'error', + message: internal ? 'The command failed inside the framework.' : `${command} failed.`, + phase: internal ? 'internal' : command, + }]; + return { schemaVersion: 2, command, diagnostics, success: false }; +} + +/** 以统一 CLI 格式写出已由命令层脱敏分类的预期失败。 */ +export function writeKnownFailure( + command: string, + diagnostics: readonly CliFailureDiagnostic[], + json: boolean | undefined, +): void { + /** 命令层只允许提交稳定的用户可见诊断。 */ + const report: CliFailureReport = { schemaVersion: 2, command, diagnostics, success: false }; + if (json) { + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + return; + } + for (const diagnostic of report.diagnostics) + process.stderr.write(`${diagnostic.severity} ${diagnostic.code}: ${diagnostic.message}\n`); +} + +/** 展示尚未进入 Core 报告阶段的失败。 */ +export function writeFailure(command: string, error: unknown, json: boolean | undefined, internal: boolean): void { + /** 从未知异常收敛出的安全失败报告。 */ + const report = failureReport(command, error, internal); + writeKnownFailure(command, report.diagnostics, json); +} + +/** 根据最终结构化诊断区分成功、项目失败和框架内部失败。 */ +export function exitCodeFor(report: BuildReport): 0 | 1 | 2 { + if (report.success) + return 0; + return report.diagnostics.some(diagnostic => diagnostic.code === 'INTERNAL_ERROR') ? 2 : 1; +} diff --git a/packages/acplugin/src/cli/program.ts b/packages/acplugin/src/cli/program.ts new file mode 100644 index 0000000..dbfa247 --- /dev/null +++ b/packages/acplugin/src/cli/program.ts @@ -0,0 +1,62 @@ +import process from 'node:process'; +import { Command, CommanderError } from 'commander'; +import { ACPLUGIN_VERSION } from '../index.js'; +import { registerBuildCommand } from './commands/build.js'; +import { registerDevCommand } from './commands/dev.js'; +import { registerInitCommand } from './commands/init.js'; +import { registerInspectCommand } from './commands/inspect.js'; +import { registerMigrateCommand } from './commands/migrate.js'; +import { registerValidateCommand } from './commands/validate.js'; + +/** 构造完整 Commander 命令树,但不读取 argv 或退出进程。 */ +export function createCli(): Command { + /** 注册全局元数据和错误处理策略的 CLI 根命令。 */ + const program = new Command() + .name('acplugin') + .description('Build canonical AI plugin deliveries for configured Platforms') + .version(ACPLUGIN_VERSION) + .showHelpAfterError() + .exitOverride(); + + registerInitCommand(program); + registerMigrateCommand(program); + registerValidateCommand(program); + registerInspectCommand(program); + registerBuildCommand(program); + registerDevCommand(program); + return program; +} + +/** 解析 CLI 参数并把使用错误与框架内部错误映射为稳定退出码。 */ +export async function main(argv: readonly string[] = process.argv): Promise { + /** 当前调用独占的 Commander 命令树。 */ + const program = createCli(); + if (argv.length <= 2) { + program.outputHelp(); + return; + } + try { + /** `--` 之前用于识别已移除参数的真实选项候选。 */ + const argumentsAfterBinary = argv.slice(2); + /** Commander option 终止符位置。 */ + const terminator = argumentsAfterBinary.indexOf('--'); + /** 不包含位置参数文本的选项扫描范围。 */ + const scanned = terminator === -1 ? argumentsAfterBinary : argumentsAfterBinary.slice(0, terminator); + if (scanned.some(argument => argument === '--target' || argument === '-t' || argument.startsWith('--target='))) { + program.error('option \'--target\' has been removed; use \'--platform \' instead', { + exitCode: 2, + code: 'acplugin.legacyTarget', + }); + } + await program.parseAsync(argv); + } catch (error) { + if (error instanceof CommanderError) { + if (error.code === 'commander.helpDisplayed' || error.code === 'commander.version') + return; + process.exitCode = 2; + return; + } + process.stderr.write('internal error: the CLI failed inside the framework\n'); + process.exitCode = 2; + } +} diff --git a/packages/acplugin/src/ecosystem/framework-version.ts b/packages/acplugin/src/ecosystem/framework-version.ts new file mode 100644 index 0000000..d34be24 --- /dev/null +++ b/packages/acplugin/src/ecosystem/framework-version.ts @@ -0,0 +1,4 @@ +import { PUBLIC_PACKAGE_VERSIONS } from './versions.js'; + +/** 当前 CLI 与公开运行时 API 的单一版本常量。 */ +export const ACPLUGIN_VERSION: string = PUBLIC_PACKAGE_VERSIONS['@tokenroll/acplugin']; diff --git a/packages/acplugin/src/ecosystem/versions.json b/packages/acplugin/src/ecosystem/versions.json new file mode 100644 index 0000000..56b4a0f --- /dev/null +++ b/packages/acplugin/src/ecosystem/versions.json @@ -0,0 +1,11 @@ +{ + "@tokenroll/acplugin": "0.0.3-beta", + "@tokenroll/acplugin-platform-claude-code": "0.0.3-beta", + "@tokenroll/acplugin-platform-codex": "0.0.4-beta", + "@tokenroll/acplugin-platform-cursor": "0.0.3-beta", + "@tokenroll/acplugin-platform-antigravity": "0.0.3-beta", + "@tokenroll/acplugin-platform-opencode": "0.0.3-beta", + "@tokenroll/acplugin-platform-pi": "0.0.3-beta", + "@tokenroll/acplugin-extension-hooks": "0.0.3-beta", + "@tokenroll/acplugin-extension-mcp": "0.0.3-beta" +} diff --git a/packages/acplugin/src/ecosystem/versions.ts b/packages/acplugin/src/ecosystem/versions.ts new file mode 100644 index 0000000..48048f3 --- /dev/null +++ b/packages/acplugin/src/ecosystem/versions.ts @@ -0,0 +1,12 @@ +import versions from './versions.json' with { type: 'json' }; + +/** 正式公开包名到当前脚手架默认精确版本的单一生成快照。 */ +export type PublicPackageName = keyof typeof versions; + +/** 当前 revision 的公开生态包版本快照。 */ +export const PUBLIC_PACKAGE_VERSIONS: Readonly> = Object.freeze({ ...versions }); + +/** 返回脚手架和 Migration 共同使用的兼容依赖范围。 */ +export function publicPackageRange(name: PublicPackageName): string { + return `^${PUBLIC_PACKAGE_VERSIONS[name]}`; +} diff --git a/packages/acplugin/src/index.ts b/packages/acplugin/src/index.ts new file mode 100644 index 0000000..c425469 --- /dev/null +++ b/packages/acplugin/src/index.ts @@ -0,0 +1,82 @@ +import type { + BuildReport, + UserConfigExport, +} from '@acplugin/core/author'; +import { stableJson } from '@acplugin/core/author'; +export { createProject, ProjectConfigError, runProject } from './author/project.js'; +export { ACPLUGIN_VERSION } from './ecosystem/framework-version.js'; +export { + nodeRuntimeArtifactPath, + nodeRuntimeLicensesArtifactPath, +} from '@acplugin/core/author'; + +export { initializeProject } from './scaffolding/init.js'; +export type { InitOptions, InitPlatformId, InitResult } from './scaffolding/init.js'; + +/** + * 为 acplugin.config.ts 提供类型推断友好的恒等辅助函数。 + * + * @param config 静态配置对象或按命令和模式生成配置的函数。 + * @returns 未修改的配置导出。 + */ +export function defineConfig(config: T): T { + return config; +} + +/** + * 把已经规范化的 BuildReport 序列化为稳定 JSON 文档。 + * + * @param report Kernel v2 构建报告。 + * @returns 两空格缩进且以单个换行结尾的 JSON。 + */ +export function serializeBuildReport(report: BuildReport): string { + return stableJson(report); +} + +// 根入口只公开普通作者和程序化调用方契约;Integration 生命周期只位于 ./sdk。 +export type { + AgentCapability, + AgentModel, + AssetMode, + AssetOrigin, + BuildConfig, + BuildMode, + BuildReport, + CompatibilityEntry, + CompatibilityLevel, + ComponentReport, + ConfigCommand, + ConfigEnvironment, + CreateProjectOptions, + DevSession, + DevSessionEvent, + Diagnostic, + DiagnosticInput, + DiagnosticPhase, + ExtensionReport, + ExtensionSubject, + MetadataDisposition, + MetadataDispositionEntry, + NodeRuntimeConfig, + NodeRuntimeEntryInput, + NodeRuntimeEntryKind, + PackageAssetReport, + PackageUnitReport, + PlatformReport, + PlatformDeliveryType, + PluginAuthor, + PluginMetadata, + Project, + ProjectDevOptions, + ProjectRunOptions, + PortableNodeCompileOptions, + PortableNodeResolveOptions, + PortableNodeTransformOptions, + PublicConfig, + PublicCopyRule, + RunProjectOptions, + RuntimeReport, + SourceLocation, + UserConfig, + UserConfigExport, +} from '@acplugin/core/author'; diff --git a/packages/acplugin/src/migration/ids.ts b/packages/acplugin/src/migration/ids.ts new file mode 100644 index 0000000..8c5e36f --- /dev/null +++ b/packages/acplugin/src/migration/ids.ts @@ -0,0 +1,68 @@ +import path from 'node:path'; + +/** 规范 Component ID 接受的小写 kebab-case 格式。 */ +export const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** 按 UTF-16 code unit 比较迁移报告与生成输入,不依赖宿主 locale/ICU。 */ +export function compareCodeUnits(left: string, right: string): number { + if (left === right) + return 0; + return left < right ? -1 : 1; +} + +/** 生成旧工程内用于报告的 POSIX 相对路径。 */ +export function relative(root: string, file: string): string { + return path.relative(root, file).split(path.sep).join('/'); +} + +/** 把任意旧资源名称收敛为规范 Component ID。 */ +export function safeId(value: string): string { + /** 移除不支持字符并压缩分隔符后的候选 ID。 */ + const id = value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + return id || 'migrated-item'; +} + +/** 尚未分配最终 ID 的单个迁移资源及其稳定来源身份。 */ +export interface MigrationIdCandidate { + readonly value: T; + readonly baseId: string; + readonly sourcePath: string; +} + +/** 已获得唯一最终 ID 的迁移资源。 */ +export interface AllocatedMigrationId extends MigrationIdCandidate { + readonly id: string; +} + +/** 为一个资源类别整体分配确定 ID,先保留显式 base 再选择未占用后缀。 */ +export function allocateMigrationIds(candidates: readonly MigrationIdCandidate[]): AllocatedMigrationId[] { + /** 所有候选显式拥有的 base ID;冲突项不得抢占这些名称。 */ + const reserved = new Set(candidates.map(candidate => candidate.baseId)); + /** 已实际分配给前序候选的最终 ID。 */ + const assigned = new Set(); + /** 每个 base 下一次尝试的数字后缀。 */ + const nextSuffix = new Map(); + /** 与发现顺序无关的候选处理顺序。 */ + const ordered = [...candidates].sort((left, right) => + compareCodeUnits(left.baseId, right.baseId) + || compareCodeUnits(left.sourcePath, right.sourcePath)); + /** 完成 winner/后缀选择后再按最终 ID 固定写入与报告顺序。 */ + const allocated = ordered.map((candidate) => { + /** 当前候选优先使用的 base,冲突时再选择数字后缀。 */ + let id = candidate.baseId; + if (assigned.has(id)) { + /** 从 `-2` 开始且会跨候选记忆的当前后缀。 */ + let suffix = nextSuffix.get(candidate.baseId) ?? 2; + do { + id = `${candidate.baseId}-${suffix}`; + suffix += 1; + } while (reserved.has(id) || assigned.has(id)); + nextSuffix.set(candidate.baseId, suffix); + } + assigned.add(id); + return { ...candidate, id }; + }); + return allocated.sort((left, right) => + compareCodeUnits(left.id, right.id) + || compareCodeUnits(left.sourcePath, right.sourcePath)); +} diff --git a/packages/acplugin/src/migration/index.ts b/packages/acplugin/src/migration/index.ts new file mode 100644 index 0000000..0996ed5 --- /dev/null +++ b/packages/acplugin/src/migration/index.ts @@ -0,0 +1,225 @@ +/** Legacy Migration 的来源识别、stage 与 commit 编排。 */ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { stableJson, type Diagnostic } from '@acplugin/core'; +import { + cleanupTempDir, + downloadGitHubRepo, + getTempRoot, + parseGitHubSource, +} from './legacy/github.js'; +import { scanClaudeProject } from './legacy/scanner/claude.js'; +import { + hasMarketplace, + isSinglePlugin, + scanAllPlugins, + scanMarketplaceMeta, + scanPlugin, +} from './legacy/scanner/plugin.js'; +import { + allocateMigrationIds, + relative, + safeId, +} from './ids.js'; +import type { + MigrationFieldDraft, + MigrationItem, + MigrationOptions, + MigrationReport, +} from './types.js'; +import { writeCanonicalProject } from './writers/project.js'; +import { + copyText, + exists, + migrationItem, +} from './writers/shared.js'; + +export type { + MigrationField, + MigrationFieldOutcome, + MigrationItem, + MigrationOptions, + MigrationOutcome, + MigrationReport, +} from './types.js'; + +/** + * 判断来源文本是否采用支持的 GitHub URL、前缀或 owner/repo 简写。 + * + * @param source 用户传入的来源字符串。 + * @returns 符合 GitHub 来源语法时返回 true。 + */ +function isGitHubSource(source: string): boolean { + return source.startsWith('github:') + || /^https?:\/\/github\.com\//.test(source) + || (/^[A-Za-z0-9_-]+\/[A-Za-z0-9._-]+(?:#.+)?$/.test(source) && !path.isAbsolute(source)); +} + +/** + * 验证最终目标尚不存在且位于旧来源树外。 + * + * @param sourceRoot 旧来源根目录。 + * @param destination 计划提交的新工程目录。 + */ +async function assertDestination(sourceRoot: string, destination: string): Promise { + if (await exists(destination)) + throw new Error('Migration destination must not exist.'); + /** 目标相对于来源的路径,用于阻止覆盖或嵌套写入旧工程。 */ + const relation = path.relative(sourceRoot, destination); + if (relation === '' || (!path.isAbsolute(relation) && relation !== '..' && !relation.startsWith(`..${path.sep}`))) + throw new Error('Migration destination must be outside the source tree.'); +} + +/** + * 执行 Legacy 来源识别、阶段生成、Core 验证和最终目录提交。 + * + * 所有内容先写入隔离阶段目录;只有报告成功且非 dry-run 时才通过 rename 提交。 + * GitHub 下载目录和迁移阶段目录都会在成功或失败后清理。 + * + * @param options 来源、目标、Marketplace 选择和保真度策略。 + * @returns 不包含旧配置敏感值的稳定迁移报告。 + */ +export async function migrate(options: MigrationOptions): Promise { + /** 解析本地相对路径使用的绝对工作目录。 */ + const cwd = path.resolve(options.cwd ?? process.cwd()); + /** 本地来源或下载后仓库子目录的绝对根路径。 */ + let sourceRoot: string; + /** GitHub 来源使用的临时下载目录清理函数。 */ + let cleanup: (() => void) | undefined; + if (isGitHubSource(options.source) && !await exists(path.resolve(cwd, options.source))) { + /** 完成格式和字段验证的 GitHub 来源。 */ + const source = parseGitHubSource(options.source); + if (options.subPath) + source.subPath = options.subPath; + sourceRoot = await downloadGitHubRepo(source); + /** 下载仓库对应的临时根目录。 */ + const temporaryRoot = getTempRoot(sourceRoot); + cleanup = () => cleanupTempDir(temporaryRoot); + } else { + sourceRoot = path.resolve(cwd, options.source); + } + + try { + if (await exists(path.join(sourceRoot, 'acplugin.config.ts'))) + throw new Error('Source is already a canonical acplugin project.'); + /** 验证成功后才会出现的最终目标绝对路径。 */ + const destination = path.resolve(cwd, options.destination ?? `${path.basename(sourceRoot)}-acplugin`); + await assertDestination(sourceRoot, destination); + /** dry-run 使用系统临时目录,真实迁移使用目标同级目录以支持 rename 提交。 */ + const stageParent = options.dryRun ? os.tmpdir() : path.dirname(destination); + if (!options.dryRun) + await fs.mkdir(stageParent, { recursive: true }); + /** 当前迁移独占且失败时完整删除的阶段目录。 */ + const stage = await fs.mkdtemp(path.join(stageParent, `.${path.basename(destination)}.migration-`)); + /** 自动识别的旧来源结构类型。 */ + let sourceType: MigrationReport['sourceType']; + /** 阶段目录内生成的规范项目路径。 */ + const projects: string[] = []; + /** 全部资源迁移结论。 */ + const items: MigrationItem[] = []; + /** 对全部生成工程执行 Core 校验得到的诊断。 */ + const diagnostics: Diagnostic[] = []; + try { + if (hasMarketplace(sourceRoot)) { + sourceType = 'marketplace'; + /** Marketplace 聚合元数据只进入迁移记录,不复制到各单 Plugin 配置。 */ + const marketplace = scanMarketplaceMeta(sourceRoot); + if (marketplace) { + /** 聚合字段统一来自 Marketplace 清单,并只指向迁移报告。 */ + const source = '.claude-plugin/marketplace.json'; + /** 不会自动重建的 Marketplace 聚合字段。 */ + const fields: MigrationFieldDraft[] = [{ + field: 'name', source, outcome: 'unmapped', + reason: 'Marketplace aggregation is recorded but not rebuilt automatically.', + }]; + if (marketplace.owner) { + fields.push({ + field: 'owner', source, outcome: 'unmapped', + reason: 'Marketplace owner remains aggregation metadata for manual publishing.', + }); + } + fields.push({ + field: 'plugin-order', source, outcome: 'unmapped', + reason: 'Original Plugin order remains available in the source Marketplace manifest for manual publishing.', + }); + items.push(migrationItem({ kind: 'marketplace', id: marketplace.name, source }, fields)); + } + /** Marketplace 中成功扫描的全部 Plugin。 */ + const plugins = scanAllPlugins(sourceRoot); + if (options.all && options.plugin !== undefined) + throw new Error('Marketplace migration accepts either --plugin or --all, not both.'); + /** CLI --all 或 --plugin 选择的迁移对象。 */ + const selected = options.all ? plugins : plugins.filter(plugin => plugin.meta.name === options.plugin); + if (selected.length === 0) + throw new Error('Marketplace migration requires --plugin or --all.'); + if (options.all) { + /** 所有 workspace 成员先全局预留 base,避免目录覆盖和后缀抢占。 */ + const workspaceProjects = allocateMigrationIds(selected.map(plugin => ({ + value: plugin, + baseId: safeId(plugin.meta.name), + sourcePath: relative(sourceRoot, plugin.rootDir), + }))); + // 只有批量迁移创建 workspace;每个成员仍是带独立配置的单 Plugin 工程。 + for (const allocated of workspaceProjects) { + /** 当前已完成全局目录 ID 分配的 Marketplace Plugin。 */ + const plugin = allocated.value; + /** Marketplace 工作区成员使用的唯一规范目录 ID。 */ + const id = allocated.id; + /** 当前成员在迁移阶段目录中的根路径。 */ + const projectRoot = path.join(stage, id); + /** 当前成员生成和重新扫描的结果。 */ + const result = await writeCanonicalProject(plugin, projectRoot, { ...options, name: id }); + items.push(...result.items.map((item) => { + /** Workspace 成员前缀必须同时应用到资源与每个字段的目标路径。 */ + const destination = item.destination ? `${id}/${item.destination}` : undefined; + return { + ...item, + ...(destination === undefined ? {} : { destination }), + fields: item.fields.map(field => ({ ...field, destination: `${id}/${field.destination}` })), + }; + })); + diagnostics.push(...result.diagnostics); + projects.push(id); + } + await copyText(path.join(stage, 'pnpm-workspace.yaml'), `packages:\n${projects.map(project => ` - ${project}`).join('\n')}\n`); + } else { + /** 单项选择直接写到 destination 根,不保留多余的 Marketplace 成员层级。 */ + const result = await writeCanonicalProject(selected[0]!, stage, options); + items.push(...result.items); + diagnostics.push(...result.diagnostics); + projects.push('.'); + } + } else { + sourceType = isSinglePlugin(sourceRoot) ? 'plugin' : 'project'; + /** 根据来源类型调用对应 Legacy Scanner 的结果。 */ + const scan = sourceType === 'plugin' ? scanPlugin(sourceRoot) : scanClaudeProject(sourceRoot); + /** 单工程生成和重新扫描的结果。 */ + const result = await writeCanonicalProject(scan, stage, options); + items.push(...result.items); + diagnostics.push(...result.diagnostics); + projects.push('.'); + } + /** 是否存在语义降级或需要人工处理的资源。 */ + const hasLoss = items.some(item => item.outcome === 'degraded' || item.outcome === 'unmapped'); + /** Core 无错误且满足可选 strict 无损条件时才允许提交。 */ + const success = !diagnostics.some(diagnostic => diagnostic.severity === 'error') && !(options.strict && hasLoss); + /** 在阶段目录中先写入、提交后随工程一同保留的最终报告。 */ + const report: MigrationReport = { + schemaVersion: '1', sourceType, projects, items, diagnostics, + success, dryRun: options.dryRun ?? false, + }; + await copyText(path.join(stage, '.acplugin-migration/report.json'), stableJson(report)); + if (success && !options.dryRun) + await fs.rename(stage, destination); + else + await fs.rm(stage, { recursive: true, force: true }); + return report; + } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { + await fs.rm(stage, { recursive: true, force: true }); + throw error; + } + } finally { + cleanup?.(); + } +} diff --git a/packages/acplugin/src/migration/legacy/github.ts b/packages/acplugin/src/migration/legacy/github.ts new file mode 100644 index 0000000..3db7f0a --- /dev/null +++ b/packages/acplugin/src/migration/legacy/github.ts @@ -0,0 +1,354 @@ +import * as https from 'https'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { execFileSync } from 'child_process'; + +/** 完成解析和校验的 GitHub 仓库来源。 */ +export interface GitHubSource { + /** GitHub 组织或用户名称。 */ + owner: string; + /** 仓库名称,不含 `.git`。 */ + repo: string; + /** 可选的分支、Tag 或其他安全 Git Ref。 */ + branch?: string; + /** 可选的仓库内部相对目录。 */ + subPath?: string; +} + +/** GitHub Owner 名称接受的格式和长度。 */ +const OWNER_PATTERN = /^(?!-)[A-Za-z0-9-]{1,39}(? character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127 || ' ~^:?*[\\'.includes(character))) + return false; + return value.split('/').every(part => part !== '' && !part.startsWith('.') && !part.endsWith('.lock')); +} + +/** + * 判断候选路径是否位于指定根目录内。 + * + * @param root 可信根目录。 + * @param candidate 待验证路径。 + * @returns 候选路径未逃逸时返回 true。 + */ +function isInside(root: string, candidate: string): boolean { + /** 基于真实路径层级而非字符串前缀的相对关系。 */ + const relation = path.relative(root, candidate); + return relation === '' || (!path.isAbsolute(relation) && relation !== '..' && !relation.startsWith(`..${path.sep}`)); +} + +/** + * 在可信根目录内解析用户相对路径。 + * + * @param root 可信根目录。 + * @param value 用户提供的相对路径。 + * @param label 错误消息使用的字段名称。 + * @returns 未逃逸根目录的绝对路径。 + */ +function resolveInside(root: string, value: string, label: string): string { + if (value.includes('\0') || path.isAbsolute(value)) + throw new Error(`${label} must be a relative path inside the repository.`); + /** 解析后的候选绝对路径。 */ + const resolved = path.resolve(root, value); + if (!isInside(path.resolve(root), resolved)) + throw new Error(`${label} must stay inside the repository.`); + return resolved; +} + +/** + * 解析并验证下载仓库内的可选子路径,包括符号链接后的真实路径。 + * + * @param root 下载或解压后的仓库根目录。 + * @param subPath 可选的仓库内部目录。 + * @returns 存在且真实路径仍位于仓库内的目录。 + */ +function repositorySubPath(root: string, subPath: string | undefined): string { + if (subPath === undefined) + return root; + /** 尚未解析符号链接的仓库内候选路径。 */ + const resolved = resolveInside(root, subPath, 'GitHub sub-path'); + if (!fs.existsSync(resolved)) + throw new Error(`GitHub sub-path "${subPath}" was not found.`); + /** 仓库根目录解析符号链接后的真实路径。 */ + const realRoot = fs.realpathSync(root); + /** 子路径解析符号链接后的真实路径。 */ + const realResolved = fs.realpathSync(resolved); + if (!isInside(realRoot, realResolved)) + throw new Error('GitHub sub-path resolves outside the repository.'); + return realResolved; +} + +/** + * 把受支持的 GitHub 来源字符串解析为结构化字段。 + * + * 支持 `github:owner/repo[#ref]`、GitHub URL 与 `owner/repo[#ref]` 简写。 + * github:owner/repo + * github:owner/repo#branch + * https://github.com/owner/repo + * https://github.com/owner/repo/tree/branch + * https://github.com/owner/repo/tree/branch/sub/path + * owner/repo + * owner/repo#branch + * + * @param source 用户提供的 GitHub 来源。 + * @returns 完成字段和路径校验的来源对象。 + */ +export function parseGitHubSource(source: string): GitHubSource { + /** 移除可选协议前缀后参与语法解析的文本。 */ + let cleaned = source; + + // 移除便于 CLI 区分本地路径的 github: 前缀。 + if (cleaned.startsWith('github:')) { + cleaned = cleaned.slice('github:'.length); + } + + // 完整 GitHub URL 可同时编码 Ref 和仓库子路径。 + /** 完整 GitHub URL 的字段捕获结果。 */ + const urlMatch = cleaned.match( + /^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?(?:\/tree\/([^/]+)(?:\/(.+))?)?$/, + ); + if (urlMatch) { + return validateSource({ + owner: urlMatch[1], + repo: urlMatch[2], + branch: urlMatch[3] || undefined, + subPath: urlMatch[4] || undefined, + }); + } + + // owner/repo 简写使用井号携带可选 Ref。 + /** 简写中提取的可选 Git Ref。 */ + let branch: string | undefined; + /** 简写中井号分隔符的位置。 */ + const hashIdx = cleaned.indexOf('#'); + if (hashIdx !== -1) { + branch = cleaned.slice(hashIdx + 1); + cleaned = cleaned.slice(0, hashIdx); + } + + /** owner/repo 简写的两个路径片段。 */ + const parts = cleaned.split('/'); + if (parts.length !== 2) { + throw new Error( + `Invalid GitHub source: "${source}". Expected format: github:owner/repo or owner/repo`, + ); + } + + return validateSource({ + owner: parts[0], + repo: parts[1], + branch, + }); +} + +/** + * 把 GitHub 仓库下载到系统临时目录。 + * + * 优先执行不下载子模块的浅克隆;Git 不可用时回退到 GitHub 生成的 tarball。 + * + * @param source 已解析或待再次验证的 GitHub 来源。 + * @returns 克隆/解压后的仓库根目录或安全子路径。 + */ +export async function downloadGitHubRepo(source: GitHubSource): Promise { + /** 当前下载独占、失败时完整清理的系统临时目录。 */ + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'acplugin-')); + /** 在产生任何网络或进程副作用前重新校验的来源。 */ + const verified = validateSource(source); + try { + // 浅克隆不会初始化不受信任仓库声明的 submodule。 + if (isGitAvailable()) + return cloneWithGit(verified, tmpDir); + + // Git 不可用时下载 GitHub 生成的归档。 + return await downloadTarball(verified, tmpDir); + } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { + cleanupTempDir(tmpDir); + throw error; + } +} + +/** + * 判断当前环境是否可执行 Git。 + * + * @returns `git --version` 成功时返回 true。 + */ +function isGitAvailable(): boolean { + try { + execFileSync('git', ['--version'], { stdio: 'pipe' }); + return true; + } catch { + return false; + } +} + +/** + * 使用参数数组执行安全浅克隆,不通过 Shell 拼接不受信任字段。 + * + * @param source 已验证 GitHub 来源。 + * @param tmpDir 当前下载临时目录。 + * @returns 仓库根目录或验证后的子路径。 + */ +function cloneWithGit(source: GitHubSource, tmpDir: string): string { + /** 由已验证 owner/repo 构造的 HTTPS Clone URL。 */ + const repoUrl = `https://github.com/${source.owner}/${source.repo}.git`; + /** 临时目录内固定的克隆目标。 */ + const cloneDir = path.join(tmpDir, 'repository'); + + /** 传给 execFileSync 的独立 Git 参数,`--` 终止选项解析。 */ + const args = ['clone', '--depth', '1']; + if (source.branch) { + args.push('--branch', source.branch); + } + args.push('--', repoUrl, cloneDir); + + execFileSync('git', args, { stdio: 'pipe' }); + return repositorySubPath(cloneDir, source.subPath); +} + +/** + * 下载并解压 GitHub 生成的仓库 tarball。 + * + * @param source 已验证 GitHub 来源。 + * @param tmpDir 当前下载临时目录。 + * @returns 解压仓库根目录或验证后的子路径。 + */ +async function downloadTarball(source: GitHubSource, tmpDir: string): Promise { + /** 未指定 Ref 时由 GitHub 解析默认分支的归档标识。 */ + const branch = source.branch || 'HEAD'; + /** 仅指向 GitHub API 允许主机的归档 URL。 */ + const tarballUrl = `https://api.github.com/repos/${source.owner}/${source.repo}/tarball/${encodeURIComponent(branch)}`; + /** 临时目录内固定的归档文件路径。 */ + const tarballPath = path.join(tmpDir, 'repo.tar.gz'); + + // 下载函数会限制重定向次数和允许的 GitHub 主机。 + await downloadFile(tarballUrl, tarballPath); + + // 使用参数数组调用系统 tar,不执行 Shell。 + execFileSync('tar', ['-xzf', tarballPath, '-C', tmpDir], { stdio: 'pipe' }); + + // GitHub 归档始终带 owner-repo-sha 形式的顶层目录。 + /** 解压后临时目录的一级内容。 */ + const entries = fs.readdirSync(tmpDir, { withFileTypes: true }); + /** GitHub 归档创建的顶层仓库目录。 */ + const extractedDir = entries.find(e => e.isDirectory()); + if (!extractedDir) { + throw new Error('Failed to extract repository archive'); + } + + /** 完成真实路径边界检查的仓库目录或子路径。 */ + const repoDir = repositorySubPath(path.join(tmpDir, extractedDir.name), source.subPath); + + // 解压成功后删除原始归档,最终清理只需处理目录树。 + fs.unlinkSync(tarballPath); + + return repoDir; +} + +/** + * 清理由 downloadGitHubRepo 创建的临时目录。 + * + * @param tmpDir 待删除临时根目录。 + */ +export function cleanupTempDir(tmpDir: string): void { + // 只允许删除系统临时目录内的后代,绝不删除临时目录本身。 + if (isInside(path.resolve(os.tmpdir()), path.resolve(tmpDir)) && path.resolve(tmpDir) !== path.resolve(os.tmpdir())) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +/** + * 从下载仓库或子路径反推出本次下载的临时根目录。 + * + * @param repoDir downloadGitHubRepo 返回的仓库路径。 + * @returns 系统临时目录下的第一级下载目录。 + */ +export function getTempRoot(repoDir: string): string { + /** 当前系统临时目录。 */ + const tmpBase = os.tmpdir(); + /** 仓库路径相对于系统临时目录的位置。 */ + const relative = path.relative(tmpBase, repoDir); + /** 属于本次下载的第一级临时目录名称。 */ + const firstSegment = relative.split(path.sep)[0]; + return path.join(tmpBase, firstSegment); +} + +/** + * 通过 HTTPS 下载文件,并只跟随 GitHub 官方主机间的有限次重定向。 + * + * @param url 当前下载或重定向 URL。 + * @param destPath 归档写入路径。 + * @param redirectCount 已跟随的重定向次数。 + * @returns 文件流完成写入时兑现的 Promise。 + */ +function downloadFile(url: string, destPath: string, redirectCount = 0): Promise { + if (redirectCount > 5) { + return Promise.reject(new Error('Too many redirects')); + } + + return new Promise((resolve, reject) => { + /** 完成协议和主机验证的当前请求 URL。 */ + const parsed = new URL(url); + /** GitHub API 归档下载允许跳转的官方主机集合。 */ + const allowedHosts = new Set(['api.github.com', 'github.com', 'codeload.github.com']); + if (parsed.protocol !== 'https:' || !allowedHosts.has(parsed.hostname)) { + reject(new Error('GitHub download redirect was rejected.')); + return; + } + /** 当前 HTTPS 下载请求;错误统一传递给 Promise。 */ + const req = https.get(parsed, { + headers: { + 'User-Agent': 'acplugin/1.0', + 'Accept': 'application/vnd.github+json', + ...(parsed.hostname === 'api.github.com' && process.env.GITHUB_TOKEN ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } : {}), + }, + }, (res) => { + // 只通过递归入口继续重定向,以重复执行协议、主机和次数校验。 + if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + resolve(downloadFile(res.headers.location, destPath, redirectCount + 1)); + return; + } + + if (res.statusCode !== 200) { + reject(new Error(`GitHub API returned ${res.statusCode}. Check that the repository exists and is accessible.`)); + return; + } + + /** 把响应体落盘到固定归档路径的文件流。 */ + const fileStream = fs.createWriteStream(destPath); + res.pipe(fileStream); + fileStream.on('finish', () => { + fileStream.close(); + resolve(); + }); + fileStream.on('error', reject); + }); + req.on('error', reject); + }); +} diff --git a/src/scanner/claude.ts b/packages/acplugin/src/migration/legacy/scanner/claude.ts similarity index 54% rename from src/scanner/claude.ts rename to packages/acplugin/src/migration/legacy/scanner/claude.ts index 4413384..e11d77b 100644 --- a/src/scanner/claude.ts +++ b/packages/acplugin/src/migration/legacy/scanner/claude.ts @@ -4,31 +4,50 @@ import { parseFrontmatter } from '../utils/frontmatter.js'; import type { ScanResult, Skill, SkillFrontmatter, SkillAuxFile, Instruction, MCPConfig, MCPServer, Agent, AgentFrontmatter, Command, Hooks } from '../types.js'; /** - * Scan a Claude Code project directory (.claude/ structure). + * 扫描旧 Claude Code 工程的 `.claude/` 结构和根级配置。 + * + * @param rootDir 旧工程根目录。 + * @returns 供隔离迁移层消费的宽松 ScanResult。 */ export function scanClaudeProject(rootDir: string): ScanResult { + /** Claude Project 固定的旧 Hooks 配置文件。 */ + const hooksSourcePath = path.join(rootDir, '.claude', 'settings.json'); + /** 从 Settings 中容错读取的 Hooks 映射。 */ + const hooks = scanSettingsHooks(hooksSourcePath); return { skills: scanSkillsDir(path.join(rootDir, '.claude', 'skills')), instructions: scanInstructions(rootDir), mcp: scanMCPJson(path.join(rootDir, '.mcp.json')), agents: scanAgentsDir(path.join(rootDir, '.claude', 'agents')), commands: scanCommandsDir(path.join(rootDir, '.claude', 'commands')), - hooks: scanSettingsHooks(path.join(rootDir, '.claude', 'settings.json')), + hooks, + ...(hooks === null ? {} : { hooksSourcePath }), pluginFiles: [], rootDir, }; } -// --- Reusable scanning functions (also used by plugin scanner) --- +// 以下宽松扫描函数也由旧 Plugin Scanner 复用。 +/** + * 扫描一级 Skill 目录,并对不规范 Frontmatter 采用保留正文的容错策略。 + * + * @param skillsDir 旧 Skills 根目录。 + * @returns 成功读取的旧 Skill 列表。 + */ export function scanSkillsDir(skillsDir: string): Skill[] { + /** 当前目录累计发现的旧 Skills。 */ const skills: Skill[] = []; for (const dir of listDirs(skillsDir)) { + /** 当前旧 Skill 的主 Markdown 路径。 */ const skillFile = path.join(dir, 'SKILL.md'); + /** 主 Markdown 内容;缺失或读取失败时跳过该目录。 */ const content = readFile(skillFile); if (!content) continue; + /** 无论 Frontmatter 是否有效都需要保留的辅助文件。 */ const auxFiles = scanSkillAuxFiles(dir); try { + /** 成功解析的旧 Skill Frontmatter 与正文。 */ const { data, body } = parseFrontmatter(content); skills.push({ dirName: path.basename(dir), @@ -38,7 +57,7 @@ export function scanSkillsDir(skillsDir: string): Skill[] { auxFiles, }); } catch { - // Skip files with invalid frontmatter + // Frontmatter 无效时保留完整原文,让迁移报告标记降级而非丢弃资源。 skills.push({ dirName: path.basename(dir), frontmatter: {}, @@ -52,29 +71,41 @@ export function scanSkillsDir(skillsDir: string): Skill[] { } /** - * Scan all auxiliary files in a skill directory (everything except SKILL.md). - * Includes files in subdirectories like references/, scripts/, assets/. + * 递归扫描 Skill 目录中除 SKILL.md 外的全部辅助文件。 + * + * @param skillDir 单个旧 Skill 根目录。 + * @returns references、scripts、assets 等子目录中的辅助文件。 */ function scanSkillAuxFiles(skillDir: string): SkillAuxFile[] { + /** 旧 Skill 目录下递归发现的全部文件。 */ const allFiles = listFilesRecursive(skillDir); + /** 排除主文件后保留的辅助文件。 */ const auxFiles: SkillAuxFile[] = []; for (const file of allFiles) { + /** 当前文件相对于旧 Skill 根目录的路径。 */ const relativePath = path.relative(skillDir, file); if (relativePath === 'SKILL.md') continue; - const content = readFile(file); - if (content !== null) { - auxFiles.push({ relativePath, content }); - } + // 辅助文件可能是图片、压缩包或其他二进制内容,只记录可信来源路径,迁移阶段按字节复制。 + auxFiles.push({ relativePath, sourcePath: file }); } return auxFiles; } +/** + * 扫描一级 Agent Markdown,并对无效 Frontmatter 保留完整正文。 + * + * @param agentsDir 旧 Agents 根目录。 + * @returns 成功读取的旧 Agent 列表。 + */ export function scanAgentsDir(agentsDir: string): Agent[] { + /** 当前目录累计发现的旧 Agents。 */ const agents: Agent[] = []; for (const file of listFiles(agentsDir, '\\.md$')) { + /** 当前旧 Agent Markdown 的完整内容。 */ const content = readFile(file); if (!content) continue; try { + /** 成功解析的旧 Agent Frontmatter 与正文。 */ const { data, body } = parseFrontmatter(content); agents.push({ fileName: path.basename(file, '.md'), @@ -83,7 +114,7 @@ export function scanAgentsDir(agentsDir: string): Agent[] { sourcePath: file, }); } catch { - // Skip files with invalid frontmatter + // Frontmatter 无效时仍保留资源,交由迁移层报告降级。 agents.push({ fileName: path.basename(file, '.md'), frontmatter: {}, @@ -95,9 +126,17 @@ export function scanAgentsDir(agentsDir: string): Agent[] { return agents; } +/** + * 扫描一级 Command Markdown,延后到迁移阶段解析其 Frontmatter。 + * + * @param commandsDir 旧 Commands 根目录。 + * @returns 成功读取的完整 Command 文件。 + */ export function scanCommandsDir(commandsDir: string): Command[] { + /** 当前目录累计发现的旧 Commands。 */ const commands: Command[] = []; for (const file of listFiles(commandsDir, '\\.md$')) { + /** 当前旧 Command Markdown 的完整内容。 */ const content = readFile(file); if (!content) continue; commands.push({ @@ -109,13 +148,23 @@ export function scanCommandsDir(commandsDir: string): Command[] { return commands; } +/** + * 容错读取旧 `.mcp.json`,并把名称映射展开为 Server 列表。 + * + * @param mcpPath 旧 MCP 配置路径。 + * @returns JSON 可解析时的宽松配置,否则返回 null。 + */ export function scanMCPJson(mcpPath: string): MCPConfig | null { + /** 旧 MCP JSON 原文。 */ const content = readFile(mcpPath); if (!content) return null; try { + /** 未经 Schema 验证的旧 JSON 对象。 */ const data = JSON.parse(content); + /** 旧格式中 Server 名称到配置的映射。 */ const mcpServers = data.mcpServers || {}; + /** 注入映射键作为 name 后的宽松 Server 列表。 */ const servers: MCPServer[] = Object.entries(mcpServers).map(([name, config]: [string, any]) => ({ name, command: config.command, @@ -131,11 +180,19 @@ export function scanMCPJson(mcpPath: string): MCPConfig | null { } } +/** + * 从旧 `.claude/settings.json` 中容错提取 Hooks 字段。 + * + * @param settingsPath 旧 Settings 路径。 + * @returns Hooks 映射,文件缺失或 JSON 无效时返回 null。 + */ export function scanSettingsHooks(settingsPath: string): Hooks | null { + /** 旧 Settings JSON 原文。 */ const content = readFile(settingsPath); if (!content) return null; try { + /** 旧 Settings 解析出的未知 JSON 对象。 */ const data = JSON.parse(content); return data.hooks || null; } catch { @@ -143,11 +200,19 @@ export function scanSettingsHooks(settingsPath: string): Hooks | null { } } +/** + * 从旧 Plugin `hooks.json` 中容错提取 Hooks 字段。 + * + * @param hooksJsonPath 旧 Hooks JSON 路径。 + * @returns Hooks 映射,文件缺失或 JSON 无效时返回 null。 + */ export function scanHooksJson(hooksJsonPath: string): Hooks | null { + /** 旧 Hooks JSON 原文。 */ const content = readFile(hooksJsonPath); if (!content) return null; try { + /** 旧 hooks.json 解析出的未知 JSON 对象。 */ const data = JSON.parse(content); return data.hooks || null; } catch { @@ -155,19 +220,32 @@ export function scanHooksJson(hooksJsonPath: string): Hooks | null { } } +/** + * 扫描根级 CLAUDE.md 和 `.claude/rules/*.md`。 + * + * Instructions 不会自动进入规范 Plugin,只用于未映射保留和报告。 + * + * @param rootDir 旧工程根目录。 + * @returns 所有可读旧 Instructions。 + */ function scanInstructions(rootDir: string): Instruction[] { + /** 当前工程累计发现的旧 Instructions。 */ const instructions: Instruction[] = []; for (const name of ['CLAUDE.md', '.claude/CLAUDE.md']) { + /** 当前 CLAUDE.md 候选文件绝对路径。 */ const filePath = path.join(rootDir, name); + /** 当前候选 Instruction 的可选文本内容。 */ const content = readFile(filePath); if (content) { instructions.push({ fileName: path.basename(name), content, sourcePath: filePath, isRule: false }); } } + /** `.claude/rules` 旧规则目录。 */ const rulesDir = path.join(rootDir, '.claude', 'rules'); for (const file of listFiles(rulesDir, '\\.md$')) { + /** 当前旧 Rule Markdown 的可选文本内容。 */ const content = readFile(file); if (content) { instructions.push({ fileName: path.basename(file), content, sourcePath: file, isRule: true }); diff --git a/packages/acplugin/src/migration/legacy/scanner/plugin.ts b/packages/acplugin/src/migration/legacy/scanner/plugin.ts new file mode 100644 index 0000000..2f56f3b --- /dev/null +++ b/packages/acplugin/src/migration/legacy/scanner/plugin.ts @@ -0,0 +1,435 @@ +import * as path from 'path'; +import * as fs from 'fs'; +import { readFile, fileExists, listDirs, listFilesRecursive } from '../utils/fs.js'; +import { scanSkillsDir, scanAgentsDir, scanCommandsDir, scanHooksJson, scanMCPJson } from './claude.js'; +import type { PluginMeta, PluginScanResult, MarketplaceMeta, MarketplaceScanResult, MCPConfig, PluginResourceFile } from '../types.js'; + +/** + * 判断候选路径是否位于旧 Plugin 根目录内。 + * + * @param root 可信 Plugin 根目录。 + * @param candidate 待检查路径。 + * @returns 候选路径未逃逸时返回 true。 + */ +function isInside(root: string, candidate: string): boolean { + /** 基于路径层级计算的相对关系。 */ + const relation = path.relative(root, candidate); + return relation === '' || (!path.isAbsolute(relation) && relation !== '..' && !relation.startsWith(`..${path.sep}`)); +} + +/** + * 在旧 Plugin 根目录内解析资源路径,并对已存在路径检查符号链接真实位置。 + * + * @param root 可信 Plugin 根目录。 + * @param value 旧清单声明的相对路径。 + * @param label 错误消息使用的字段名称。 + * @returns 留在 Plugin 边界内的绝对路径。 + */ +function resolveInside(root: string, value: string, label: string): string { + if (value.includes('\0') || path.isAbsolute(value)) + throw new Error(`${label} must be a relative path inside the plugin.`); + /** 尚未解析符号链接的候选绝对路径。 */ + const resolved = path.resolve(root, value); + if (!isInside(path.resolve(root), resolved)) + throw new Error(`${label} must stay inside the plugin.`); + if (fs.existsSync(resolved)) { + /** Plugin 根目录解析符号链接后的真实路径。 */ + const realRoot = fs.realpathSync(root); + /** 资源路径解析符号链接后的真实路径。 */ + const realResolved = fs.realpathSync(resolved); + if (!isInside(realRoot, realResolved)) + throw new Error(`${label} resolves outside the plugin.`); + return realResolved; + } + return resolved; +} + +/** + * 判断目录是否包含旧 Claude Code Marketplace 清单。 + * + * @param rootDir 待识别来源根目录。 + * @returns 存在 marketplace.json 时返回 true。 + */ +export function hasMarketplace(rootDir: string): boolean { + return fileExists(path.join(rootDir, '.claude-plugin', 'marketplace.json')); +} + +/** + * 判断目录是否是带 plugin.json 的单个旧 Plugin。 + * + * @param rootDir 待识别来源根目录。 + * @returns 存在 plugin.json 时返回 true。 + */ +export function isSinglePlugin(rootDir: string): boolean { + return fileExists(path.join(rootDir, '.claude-plugin', 'plugin.json')); +} + +/** + * 容错读取 Marketplace 清单和其中的 Plugin 条目。 + * + * @param rootDir Marketplace 仓库根目录。 + * @returns 可解析的宽松元数据,否则返回 null。 + */ +export function scanMarketplaceMeta(rootDir: string): MarketplaceMeta | null { + /** Marketplace 清单固定路径。 */ + const marketplacePath = path.join(rootDir, '.claude-plugin', 'marketplace.json'); + /** Marketplace JSON 原文。 */ + const content = readFile(marketplacePath); + if (!content) return null; + + try { + /** 未经 Schema 验证的旧 Marketplace JSON。 */ + const data = JSON.parse(content); + return { + name: data.name || 'marketplace', + version: data.version, + description: data.description, + owner: data.owner, + metadata: data.metadata, + plugins: (data.plugins || []).map((p: any) => ({ + name: p.name, + source: p.source, + description: p.description, + version: p.version, + category: p.category, + })), + }; + } catch { + return null; + } +} + +/** + * 把 Marketplace 条目投影为 PluginMeta 列表。 + * + * @param rootDir Marketplace 仓库根目录。 + * @returns 清单有效时的 Plugin 元数据,否则返回空数组。 + */ +export function scanMarketplace(rootDir: string): PluginMeta[] { + /** 容错读取的 Marketplace 清单。 */ + const marketplace = scanMarketplaceMeta(rootDir); + if (!marketplace) return []; + + return marketplace.plugins.map(p => ({ + name: p.name, + description: p.description, + version: p.version, + source: p.source, + category: p.category, + })); +} + +/** + * 根据 Marketplace source 和可选 pluginRoot 解析实际 Plugin 目录。 + * + * @param rootDir Marketplace 仓库根目录。 + * @param source Plugin 条目的相对来源。 + * @param pluginRoot Marketplace 统一声明的可选 Plugin 根目录。 + * @returns 经过目录边界检查的 Plugin 绝对路径。 + */ +export function resolvePluginDir(rootDir: string, source: string, pluginRoot?: string): string { + if (pluginRoot) + return resolveInside(rootDir, path.join(pluginRoot, source), 'Marketplace plugin source'); + // 未配置 pluginRoot 时 source 自身就是相对于仓库根目录的路径。 + return resolveInside(rootDir, source, 'Marketplace plugin source'); +} + +/** + * 扫描单个旧 Plugin,并遵守 plugin.json 的资源路径覆盖。 + * + * @param pluginDir 旧 Plugin 根目录。 + * @param meta Marketplace 已提供的可选元数据。 + * @param metadataSource 报告使用的来源根相对元数据清单路径。 + * @returns 资源路径已经解析的完整 PluginScanResult。 + */ +export function scanPlugin( + pluginDir: string, + meta?: PluginMeta, + metadataSource = '.claude-plugin/plugin.json', +): PluginScanResult { + // Marketplace 未提供元数据时回退到 Plugin 自己的清单。 + /** 当前 Plugin 最终使用的旧元数据。 */ + const resolvedMeta = meta || readPluginMeta(pluginDir); + + // 每种资源优先采用旧清单覆盖,否则使用 Plugin 根目录下的默认位置。 + /** 旧 Skills 实际扫描目录。 */ + const skillsDir = resolvedMeta.skills + ? resolveInside(pluginDir, resolvedMeta.skills, 'Plugin skills path') + : path.join(pluginDir, 'skills'); + + /** 旧 Agents 实际扫描目录。 */ + const agentsDir = resolvedMeta.agents + ? resolveInside(pluginDir, resolvedMeta.agents as string, 'Plugin agents path') + : path.join(pluginDir, 'agents'); + + /** 旧 Commands 可能是目录或文件数组的宽松路径字段。 */ + const commandsPath = resolvedMeta.commands; + /** 当前 Scanner 能够处理的 Commands 目录。 */ + const commandsDir = typeof commandsPath === 'string' && !commandsPath.endsWith('.md') + ? resolveInside(pluginDir, commandsPath, 'Plugin commands path') + : path.join(pluginDir, 'commands'); + + /** 旧 Hooks 清单实际路径。 */ + const hooksPath = resolvedMeta.hooks + ? resolveInside(pluginDir, resolvedMeta.hooks, 'Plugin Hooks path') + : path.join(pluginDir, 'hooks', 'hooks.json'); + + // MCP 优先采用清单覆盖,否则读取 Plugin 根级 `.mcp.json`。 + /** 旧 MCP 配置实际路径。 */ + const mcpPath = resolvedMeta.mcpServers + ? resolveInside(pluginDir, resolvedMeta.mcpServers, 'Plugin MCP path') + : path.join(pluginDir, '.mcp.json'); + /** 容错解析后的旧 MCP 配置。 */ + const mcpConfig = scanMCPJson(mcpPath); + /** 容错解析后的旧 Hooks 配置。 */ + const hooks = scanHooksJson(hooksPath); + + // 保存 MCP 命令显式引用的 scripts 等 Plugin 级文件,避免迁移时静默丢失。 + /** 旧 MCP 引用的未分类 Plugin 文件。 */ + const pluginFiles = scanMCPReferencedFiles(pluginDir, mcpConfig); + + return { + meta: resolvedMeta, + metadataSource, + skills: scanSkillsDir(skillsDir), + instructions: [], + mcp: mcpConfig, + agents: scanAgentsDir(agentsDir), + commands: scanCommandsDir(commandsDir), + hooks, + ...(hooks === null ? {} : { hooksSourcePath: hooksPath }), + pluginFiles, + rootDir: pluginDir, + }; +} + +/** + * 容错读取旧 plugin.json 的元数据和资源路径覆盖。 + * + * @param pluginDir 旧 Plugin 根目录。 + * @returns 清单无效时至少包含目录回退名称的 PluginMeta。 + */ +export function readPluginMeta(pluginDir: string): PluginMeta { + /** 旧 Plugin 清单固定路径。 */ + const pluginJsonPath = path.join(pluginDir, '.claude-plugin', 'plugin.json'); + /** 旧 Plugin JSON 原文。 */ + const content = readFile(pluginJsonPath); + if (!content) { + return { name: path.basename(pluginDir) }; + } + + try { + /** 未经 Schema 验证的旧 Plugin JSON。 */ + const data = JSON.parse(content); + /** 逐步附加资源路径和展示元数据的宽松 PluginMeta。 */ + const meta: PluginMeta = { + name: data.name || path.basename(pluginDir), + description: data.description, + version: data.version, + author: data.author, + displayName: data.displayName, + homepage: data.homepage, + repository: data.repository, + license: data.license, + keywords: data.keywords, + }; + + // 保留旧清单声明的资源路径覆盖,稍后统一执行目录边界检查。 + if (data.skills) meta.skills = data.skills; + if (data.agents) meta.agents = data.agents; + if (data.commands) meta.commands = data.commands; + if (data.hooks) meta.hooks = data.hooks; + if (data.mcpServers) meta.mcpServers = data.mcpServers; + if (data.apps) meta.apps = data.apps; + + // Marketplace 展示信息只用于元数据保留,不改变 Core Component。 + if (data.interface) meta.interface = data.interface; + + return meta; + } catch { + return { name: path.basename(pluginDir) }; + } +} + +/** + * 提取旧 MCP 中 `${CLAUDE_PLUGIN_ROOT}` 引用的一级路径并递归保留其文本文件。 + * + * @param pluginDir 旧 Plugin 根目录和路径信任边界。 + * @param mcp 容错读取的旧 MCP 配置。 + * @returns MCP 命令显式引用的 Plugin 级文件。 + */ +function scanMCPReferencedFiles(pluginDir: string, mcp: MCPConfig | null): PluginResourceFile[] { + if (!mcp) return []; + /** 从命令参数和环境值提取的一级目录集合。 */ + const referencedDirs = new Set(); + + for (const server of mcp.servers) { + // 从命令参数提取 Plugin 根变量后的第一级目录。 + for (const arg of server.args || []) { + /** 当前参数内全部 Plugin 根变量路径引用。 */ + const matches = arg.matchAll(/\$\{CLAUDE_PLUGIN_ROOT\}\/([^\s"]+)/g); + for (const m of matches) { + referencedDirs.add(m[1].split('/')[0]); + } + } + // 从环境值提取 Plugin 根变量后的第一级目录。 + for (const val of Object.values(server.env || {})) { + /** 当前环境值内全部 Plugin 根变量路径引用。 */ + const matches = val.matchAll(/\$\{CLAUDE_PLUGIN_ROOT\}\/([^\s"]+)/g); + for (const m of matches) { + referencedDirs.add(m[1].split('/')[0]); + } + } + } + + /** 引用目录中成功读取的全部文本文件。 */ + const files: PluginResourceFile[] = []; + for (const dirName of referencedDirs) { + /** 经过 Plugin 边界和真实路径检查的引用目录。 */ + const dirPath = resolveInside(pluginDir, dirName, 'MCP referenced path'); + if (!fileExists(dirPath)) continue; + for (const file of listFilesRecursive(dirPath)) { + /** MCP 引用目录中当前文件的可选文本内容。 */ + const content = readFile(file); + if (content !== null) { + files.push({ + relativePath: path.relative(pluginDir, file).replace(/\\/g, '/'), + content, + }); + } + } + } + + return files; +} + +/** + * Marketplace 来源目录的推断类型。 + * + * 推断优先级为显式 plugin.json、标准资源子目录、目录名、SKILL.md 内容识别,最后为 unknown。 + */ +type SourceTargetType = 'plugin-root' | 'skills-dir' | 'agents-dir' | 'commands-dir' | 'unknown'; + +/** + * 推断 Marketplace source 指向完整 Plugin 还是单类资源目录。 + * + * @param dir 已完成仓库边界解析的来源目录。 + * @returns 后续选择扫描策略使用的来源类型。 + */ +export function analyzeSourceTarget(dir: string): SourceTargetType { + // plugin.json 是最明确的完整 Plugin 标志。 + if (fileExists(path.join(dir, '.claude-plugin', 'plugin.json'))) return 'plugin-root'; + + // 标准资源子目录也表明来源是完整 Plugin 根目录。 + if (fileExists(path.join(dir, 'skills')) || fileExists(path.join(dir, 'agents'))) return 'plugin-root'; + + // 目录名可识别直接指向某类资源的 Marketplace source。 + /** 当前来源目录的小写名称。 */ + const dirName = path.basename(dir).toLowerCase(); + if (dirName === 'skills') return 'skills-dir'; + if (dirName === 'agents') return 'agents-dir'; + if (dirName === 'commands') return 'commands-dir'; + + // 子目录存在 SKILL.md 时把来源识别为直接 Skills 目录。 + /** 用于内容识别的一级子目录。 */ + const subdirs = listDirs(dir); + for (const sub of subdirs) { + if (fileExists(path.join(sub, 'SKILL.md'))) return 'skills-dir'; + } + + return 'unknown'; +} + +/** + * 扫描 Marketplace 中的全部可解析 Plugin,并按来源类型选择扫描策略。 + * + * @param rootDir Marketplace 仓库根目录。 + * @returns 至少包含一个实际资源的 Plugin 扫描结果。 + */ +export function scanAllPlugins(rootDir: string): PluginScanResult[] { + /** 容错读取的 Marketplace 清单。 */ + const marketplace = scanMarketplaceMeta(rootDir); + if (!marketplace) return []; + + /** Marketplace 可选的统一 Plugin 根路径。 */ + const pluginRoot = marketplace.metadata?.pluginRoot; + /** 成功扫描且包含资源的 Plugin。 */ + const results: PluginScanResult[] = []; + + for (const entry of marketplace.plugins) { + if (!entry.source) continue; + /** 当前条目完成仓库边界检查的来源目录。 */ + const pluginDir = resolvePluginDir(rootDir, entry.source, pluginRoot); + if (!fileExists(pluginDir)) continue; + + /** 从 Marketplace 条目构造的 Plugin 元数据。 */ + const meta: PluginMeta = { + name: entry.name, + description: entry.description, + version: entry.version, + source: entry.source, + category: entry.category, + }; + + /** 当前来源目录推断出的扫描策略。 */ + const targetType = analyzeSourceTarget(pluginDir); + /** 当前条目最终得到的统一 Plugin 扫描结果。 */ + let result: PluginScanResult; + + switch (targetType) { + case 'skills-dir': + result = { + meta, metadataSource: '.claude-plugin/marketplace.json', skills: scanSkillsDir(pluginDir), + instructions: [], mcp: null, agents: [], commands: [], hooks: null, pluginFiles: [], rootDir: pluginDir, + }; + break; + case 'agents-dir': + result = { + meta, metadataSource: '.claude-plugin/marketplace.json', agents: scanAgentsDir(pluginDir), + skills: [], instructions: [], mcp: null, commands: [], hooks: null, pluginFiles: [], rootDir: pluginDir, + }; + break; + case 'commands-dir': + result = { + meta, metadataSource: '.claude-plugin/marketplace.json', commands: scanCommandsDir(pluginDir), + skills: [], instructions: [], mcp: null, agents: [], hooks: null, pluginFiles: [], rootDir: pluginDir, + }; + break; + default: // plugin-root 与 unknown 都按完整 Plugin 尝试扫描。 + result = scanPlugin(pluginDir, meta, '.claude-plugin/marketplace.json'); + } + + // Marketplace 选择语义以清单条目为准;MCP-only 或 metadata-only Plugin 仍是合法工程。 + results.push(result); + } + + return results; +} + +/** + * 同时返回 Marketplace 元数据和全部 Plugin 扫描结果。 + * + * @param rootDir Marketplace 仓库根目录。 + * @returns 完整扫描结果,清单无效时返回 null。 + */ +export function scanMarketplaceFull(rootDir: string): MarketplaceScanResult | null { + /** 容错读取的 Marketplace 清单。 */ + const marketplace = scanMarketplaceMeta(rootDir); + if (!marketplace) return null; + + /** Marketplace 中全部非空 Plugin 扫描结果。 */ + const plugins = scanAllPlugins(rootDir); + return { marketplace, plugins }; +} + +/** + * 统计旧 Plugin 扫描结果中全部已识别资源。 + * + * @param scan 单个 Plugin 扫描结果。 + * @returns Skills、Agents、Commands、Hooks、Instructions 和 MCP Server 总数。 + */ +export function countResources(scan: PluginScanResult): number { + return scan.skills.length + scan.agents.length + + scan.commands.length + (scan.hooks ? Object.keys(scan.hooks).length : 0) + + scan.instructions.length + (scan.mcp ? scan.mcp.servers.length : 0); +} diff --git a/packages/acplugin/src/migration/legacy/types.ts b/packages/acplugin/src/migration/legacy/types.ts new file mode 100644 index 0000000..926604b --- /dev/null +++ b/packages/acplugin/src/migration/legacy/types.ts @@ -0,0 +1,255 @@ +// 以下宽松输入类型只服务于隔离的 Legacy Scanner,不属于 Core Plugin 公开契约。 +/** 旧 Skill Frontmatter 的宽松字段集合,未知或平台专有值由迁移层决定是否降级。 */ +export interface SkillFrontmatter { + 'name'?: string; + 'description'?: string; + 'when_to_use'?: string; + 'argument-hint'?: string; + 'arguments'?: unknown; + 'disable-model-invocation'?: boolean; + 'user-invocable'?: boolean; + 'allowed-tools'?: string; + 'disallowed-tools'?: string; + 'model'?: string; + 'effort'?: string; + 'context'?: string; + 'agent'?: string; + 'background'?: boolean; + 'paths'?: string | string[]; + 'shell'?: string; + 'hooks'?: Record; +} + +/** 旧 Skill 目录中除 SKILL.md 外、需要按原始字节保留的辅助文件。 */ +export interface SkillAuxFile { + /** 相对于 Skill 目录的路径,例如 `references/doc.md`。 */ + relativePath: string; + /** 旧辅助文件的绝对来源路径,迁移时直接执行字节复制。 */ + sourcePath: string; +} + +/** Legacy Scanner 读取的完整旧 Skill。 */ +export interface Skill { + /** 旧 Skill 一级目录名称。 */ + dirName: string; + /** 容错解析后的旧 Frontmatter。 */ + frontmatter: SkillFrontmatter; + /** 去除 Frontmatter 后的 Markdown 正文。 */ + body: string; + /** 旧 SKILL.md 的绝对来源路径。 */ + sourcePath: string; + /** 需要随 Skill 一起保留的辅助文件。 */ + auxFiles: SkillAuxFile[]; +} + +/** 旧 CLAUDE.md 或 `.claude/rules` 内容;不属于规范可安装 Plugin 边界。 */ +export interface Instruction { + /** 旧指令文件名。 */ + fileName: string; + /** 原始 Markdown 内容。 */ + content: string; + /** 旧文件绝对路径。 */ + sourcePath: string; + /** 是否来自 `.claude/rules/` 而非 CLAUDE.md。 */ + isRule: boolean; +} + +/** 旧 `.mcp.json` 中一个宽松 MCP Server 条目。 */ +export interface MCPServer { + /** Server 在旧映射中的名称。 */ + name: string; + /** stdio 模式使用的本地命令。 */ + command?: string; + /** 本地命令参数;迁移报告中必须脱敏。 */ + args?: string[]; + /** 本地进程环境;迁移报告中只保留变量名称。 */ + env?: Record; + /** 旧传输标识,常见值为 http 或 stdio。 */ + type?: string; + /** 远程 MCP 端点。 */ + url?: string; + /** 远程请求 Header;迁移报告中只保留名称。 */ + headers?: Record; +} + +/** 旧 MCP 配置及其来源文件。 */ +export interface MCPConfig { + /** 从旧映射展开并注入名称的 Server 列表。 */ + servers: MCPServer[]; + /** `.mcp.json` 的绝对路径。 */ + sourcePath: string; +} + +/** 旧 Agent Frontmatter 的宽松字段集合。 */ +export interface AgentFrontmatter { + name?: string; + description?: string; + tools?: string; + disallowedTools?: string; + model?: string; + permissionMode?: string; + maxTurns?: number; + skills?: string[]; + mcpServers?: unknown[]; + hooks?: Record; + memory?: string; + background?: boolean; + effort?: string; + isolation?: string; + color?: string; + initialPrompt?: string; +} + +/** Legacy Scanner 读取的完整旧 Agent。 */ +export interface Agent { + /** 不含 `.md` 后缀的旧 Agent 文件名。 */ + fileName: string; + /** 容错解析后的旧 Frontmatter。 */ + frontmatter: AgentFrontmatter; + /** 去除 Frontmatter 后的 Markdown 正文。 */ + body: string; + /** 旧 Agent Markdown 的绝对路径。 */ + sourcePath: string; +} + +/** Legacy Scanner 读取的旧 Command Markdown。 */ +export interface Command { + /** 不含 `.md` 后缀的旧 Command 名称。 */ + name: string; + /** 保留 Frontmatter 的完整旧 Markdown。 */ + content: string; + /** 旧 Command 文件绝对路径。 */ + sourcePath: string; +} + +/** 旧 Hook Matcher 中一个命令或 URL Handler。 */ +export interface HookEntry { + /** 旧 Handler 类型。 */ + type: string; + /** 本地命令 Handler。 */ + command?: string; + /** 远程 URL Handler。 */ + url?: string; +} + +/** 旧 Hook 事件下的一组可选 Matcher 与 Handler。 */ +export interface HookMatcher { + /** 旧平台 Matcher 表达式。 */ + matcher?: string; + /** 该 Matcher 触发的 Handler。 */ + hooks: HookEntry[]; +} + +/** 旧 Hook 事件名到 Matcher 组的宽松映射。 */ +export interface Hooks { + /** 未知事件仍被保留,供人工迁移。 */ + [event: string]: HookMatcher[]; +} + +/** 旧 Marketplace Plugin 的展示层元数据。 */ +export interface PluginInterface { + displayName?: string; + shortDescription?: string; + longDescription?: string; + developerName?: string; + category?: string; + capabilities?: string[]; + websiteURL?: string; + privacyPolicyURL?: string; + termsOfServiceURL?: string; + defaultPrompt?: string[]; + brandColor?: string; + composerIcon?: string; + logo?: string; + screenshots?: string[]; +} + +/** 旧 `.claude-plugin/plugin.json` 的容错元数据和资源路径覆盖。 */ +export interface PluginMeta { + /** 旧 Plugin 机器名称。 */ + name: string; + description?: string; + version?: string; + author?: { name: string; email?: string; url?: string }; + source?: string; + category?: string; + displayName?: string; + homepage?: string; + repository?: string; + license?: string; + keywords?: string[]; + // 以下字段覆盖旧 plugin.json 中各资源类型的默认扫描位置。 + skills?: string; + agents?: string; + commands?: string | string[]; + hooks?: string; + mcpServers?: string; + apps?: string; + // Marketplace 可选的展示层元数据。 + interface?: PluginInterface; +} + +/** 旧 Marketplace 清单及其 Plugin 条目。 */ +export interface MarketplaceMeta { + name: string; + version?: string; + description?: string; + owner?: { name: string; email?: string }; + metadata?: { description?: string; version?: string; pluginRoot?: string }; + plugins: MarketplacePluginEntry[]; +} + +/** Marketplace 中一个待解析来源的 Plugin 记录。 */ +export interface MarketplacePluginEntry { + name: string; + source: string; + description?: string; + version?: string; + category?: string; +} + +/** 无法分类、需要在未映射目录中保留的 Plugin 级文本文件。 */ +export interface PluginResourceFile { + /** 相对于 Plugin 根目录的路径,例如 `scripts/mcp-server/start.js`。 */ + relativePath: string; + /** 原样保留的文本内容。 */ + content: string; +} + +/** 旧 Claude 工程扫描产生的统一宽松资源集合。 */ +export interface ScanResult { + /** 扫描到的旧 Skills。 */ + skills: Skill[]; + /** 扫描到但不会进入可安装 Plugin 的 Instructions。 */ + instructions: Instruction[]; + /** 可选的旧 MCP 配置。 */ + mcp: MCPConfig | null; + /** 扫描到的旧 Agents。 */ + agents: Agent[]; + /** 扫描到的旧 Commands。 */ + commands: Command[]; + /** 可选的旧 Hooks 配置。 */ + hooks: Hooks | null; + /** Hooks 实际读取文件的绝对路径;没有 Hooks 时省略。 */ + hooksSourcePath?: string; + /** 未分类的 Plugin 级资源文件。 */ + pluginFiles: PluginResourceFile[]; + /** 当前 ScanResult 对应的绝对来源根目录。 */ + rootDir: string; +} + +/** 在通用 ScanResult 上附加旧 Plugin 元数据。 */ +export interface PluginScanResult extends ScanResult { + /** 旧 Plugin 清单元数据。 */ + meta: PluginMeta; + /** 元数据实际来自的来源根相对清单路径。 */ + metadataSource: string; +} + +/** Marketplace 扫描清单及其中成功解析的全部 Plugin。 */ +export interface MarketplaceScanResult { + /** Marketplace 顶层元数据。 */ + marketplace: MarketplaceMeta; + /** 各 Marketplace 条目对应的完整 Plugin 扫描结果。 */ + plugins: PluginScanResult[]; +} diff --git a/packages/acplugin/src/migration/legacy/utils/frontmatter.ts b/packages/acplugin/src/migration/legacy/utils/frontmatter.ts new file mode 100644 index 0000000..03969fb --- /dev/null +++ b/packages/acplugin/src/migration/legacy/utils/frontmatter.ts @@ -0,0 +1,37 @@ +import matter from 'gray-matter'; + +/** + * 使用 gray-matter 容错解析旧 Markdown Frontmatter。 + * + * @param content 包含可选 Frontmatter 的旧 Markdown。 + * @returns 调用方指定宽松类型的元数据和正文。 + */ +export function parseFrontmatter(content: string): { data: T; body: string } { + /** gray-matter 的通用解析结果。 */ + const result = matter(content); + return { data: result.data as T, body: result.content }; +} + +/** + * 过滤空字段后把旧迁移元数据重新写为 Markdown Frontmatter。 + * + * @param data 待写入的宽松元数据。 + * @param body Markdown 正文。 + * @returns 没有有效字段时的原正文,或带 Frontmatter 的 Markdown。 + */ +export function stringifyFrontmatter(data: Record, body: string): string { + // undefined/null 不应在迁移生成的 YAML 中形成含义不明确的字段。 + /** 只保留具有实际旧值的 Frontmatter。 */ + const cleanData: Record = {}; + for (const [key, value] of Object.entries(data)) { + if (value !== undefined && value !== null) { + cleanData[key] = value; + } + } + + if (Object.keys(cleanData).length === 0) { + return body; + } + + return matter.stringify(body, cleanData); +} diff --git a/packages/acplugin/src/migration/legacy/utils/fs.ts b/packages/acplugin/src/migration/legacy/utils/fs.ts new file mode 100644 index 0000000..3857279 --- /dev/null +++ b/packages/acplugin/src/migration/legacy/utils/fs.ts @@ -0,0 +1,117 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 按 UTF-16 code unit 比较容错 Legacy Scanner 的目录项名称。 + * + * @param left 左侧名称。 + * @param right 右侧名称。 + * @returns 与 Array.sort 约定一致的 -1、0 或 1。 + */ +function compareCodeUnits(left: string, right: string): number { + if (left === right) + return 0; + return left < right ? -1 : 1; +} + +/** + * 递归创建 Legacy Scanner 或迁移写入所需目录。 + * + * @param dirPath 目标目录路径。 + */ +export function ensureDir(dirPath: string): void { + fs.mkdirSync(dirPath, { recursive: true }); +} + +/** + * 创建父目录后同步写入 UTF-8 文本。 + * + * @param filePath 目标文件路径。 + * @param content 文本内容。 + */ +export function writeFile(filePath: string, content: string): void { + ensureDir(path.dirname(filePath)); + fs.writeFileSync(filePath, content, 'utf-8'); +} + +/** + * 容错同步读取旧文本文件。 + * + * @param filePath 旧资源路径。 + * @returns UTF-8 内容;不存在或不可读时返回 null。 + */ +export function readFile(filePath: string): string | null { + try { + return fs.readFileSync(filePath, 'utf-8'); + } catch { + return null; + } +} + +/** + * 判断 Legacy Scanner 候选路径是否存在。 + * + * @param filePath 待检查路径。 + * @returns 路径存在时返回 true。 + */ +export function fileExists(filePath: string): boolean { + return fs.existsSync(filePath); +} + +/** + * 列出目录一级普通文件,并可按正则文本过滤名称。 + * + * @param dir 旧资源目录。 + * @param pattern 可选的文件名正则源码。 + * @returns 一级文件完整路径列表。 + */ +export function listFiles(dir: string, pattern?: string): string[] { + if (!fs.existsSync(dir)) return []; + /** 当前目录的一级目录项。 */ + const entries = fs.readdirSync(dir, { withFileTypes: true, recursive: false }) + .sort((left, right) => compareCodeUnits(left.name, right.name)); + return entries + .filter(e => e.isFile() && (!pattern || e.name.match(new RegExp(pattern)))) + .map(e => path.join(dir, e.name)); +} + +/** + * 列出目录一级真实子目录,不跟随符号链接。 + * + * @param dir 旧资源目录。 + * @returns 一级子目录完整路径列表。 + */ +export function listDirs(dir: string): string[] { + if (!fs.existsSync(dir)) return []; + /** 当前目录的一级目录项。 */ + const entries = fs.readdirSync(dir, { withFileTypes: true }) + .sort((left, right) => compareCodeUnits(left.name, right.name)); + return entries + .filter(e => e.isDirectory()) + .map(e => path.join(dir, e.name)); +} + +/** + * 递归列出目录中的普通文件,不跟随符号链接目录。 + * + * @param dir 旧资源根目录。 + * @returns 深度优先发现的完整文件路径列表。 + */ +export function listFilesRecursive(dir: string): string[] { + if (!fs.existsSync(dir)) return []; + /** 当前递归子树累计发现的普通文件。 */ + const results: string[] = []; + /** 当前目录的一级目录项。 */ + const entries = fs.readdirSync(dir, { withFileTypes: true }) + .sort((left, right) => compareCodeUnits(left.name, right.name)); + for (const entry of entries) { + /** 当前目录项的完整路径。 */ + const fullPath = path.join(dir, entry.name); + if (entry.isFile()) { + results.push(fullPath); + } else if (entry.isDirectory()) { + results.push(...listFilesRecursive(fullPath)); + } + } + return results; +} diff --git a/packages/acplugin/src/migration/metadata.ts b/packages/acplugin/src/migration/metadata.ts new file mode 100644 index 0000000..6041a3a --- /dev/null +++ b/packages/acplugin/src/migration/metadata.ts @@ -0,0 +1,335 @@ +/** Legacy 元数据到 canonical PluginMetadata 的逐字段规划。 */ +import path from 'node:path'; +import { input } from '@inquirer/prompts'; +import semver from 'semver'; +import parseSpdxExpression from 'spdx-expression-parse'; +import type { PluginMetadata } from '@acplugin/core'; +import type { PluginScanResult, ScanResult } from './legacy/types.js'; +import { compareCodeUnits, ID_PATTERN, safeId } from './ids.js'; +import type { MigrationFieldDraft, MigrationItem, MigrationOptions } from './types.js'; +import { migrationItem, reportField } from './writers/shared.js'; + +/** Plugin 作者邮件与 Core 配置保持一致的保守结构规则。 */ +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** 旧 JSON 中可枚举且不是数组的对象形态。 */ +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * 按 Core 规则把非空字符串去除首尾空白。 + * + * @param value 未经 Schema 验证的旧字段值。 + * @returns 可进入规范配置的字符串;类型或内容无效时返回 undefined。 + */ +function normalizedText(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined; +} + +/** + * 使用与 Core 一致的绝对 HTTP(S) URL 边界。 + * + * @param value 已去除首尾空白的 URL 候选。 + * @returns URL 具有 HTTP(S) 协议和主机名时返回 true。 + */ +function isHttpUrl(value: string): boolean { + try { + /** 标准 URL 解析结果用于拒绝相对路径和不完整主机名。 */ + const parsed = new URL(value); + return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.hostname.length > 0; + } catch { + return false; + } +} + +/** + * 判断字符串是否为 Core 接受的 SPDX 许可表达式。 + * + * @param value 已去除首尾空白的许可候选。 + * @returns SPDX Parser 接受该完整表达式时返回 true。 + */ +function isSpdxExpression(value: string): boolean { + try { + parseSpdxExpression(value); + return true; + } catch { + return false; + } +} + +/** 统一元数据字段及其旧 interface 回退来源。 */ +interface MetadataCandidate { + /** 报告中保留的精确旧字段路径。 */ + readonly field: string; + /** 未经旧 Schema 校验的字段值。 */ + readonly value: unknown; + /** 该字段是否只能作为统一字段的回退来源。 */ + readonly fallback: boolean; +} + +/** + * 从一组优先级候选选择首个合法文本,并逐项报告所有实际来源。 + * + * @param fields 当前元数据资源的字段报告。 + * @param source 旧元数据清单相对路径。 + * @param candidates 主字段和 interface 回退字段的优先级列表。 + * @param label 不包含原始值的字段说明。 + * @param validate 对规范化文本执行的可选 Core 等价校验。 + * @returns 首个合法候选的规范化值。 + */ +function selectMetadataText( + fields: MigrationFieldDraft[], + source: string, + candidates: readonly MetadataCandidate[], + label: string, + validate: (value: string) => boolean = () => true, +): string | undefined { + /** 每个实际来源的规范化结果;undefined 表示无法自动映射。 */ + const normalized = candidates.map(candidate => candidate.value === undefined + ? undefined + : normalizedText(candidate.value)); + /** 首个同时满足文本和字段专属契约的来源索引。 */ + const selectedIndex = normalized.findIndex(value => value !== undefined && validate(value)); + /** 最终进入规范配置的字段值。 */ + const selected = selectedIndex < 0 ? undefined : normalized[selectedIndex]; + for (const [index, candidate] of candidates.entries()) { + if (candidate.value === undefined) + continue; + /** 当前来源去空白后的候选文本。 */ + const value = normalized[index]; + if (value === undefined || !validate(value)) { + reportField(fields, candidate.field, source, 'unmapped', `${label} did not satisfy the canonical metadata contract.`); + } else if (index === selectedIndex) { + /** 回退选择或字符串规范化都必须在总体报告中保持 degraded。 */ + const normalizedOrFallback = candidate.fallback || value !== candidate.value; + reportField(fields, candidate.field, source, normalizedOrFallback ? 'degraded' : 'mapped', normalizedOrFallback + ? `${label} required fallback selection or whitespace normalization.` + : `${label} maps directly to the corresponding top-level config field.`); + } else if (value === selected) { + reportField(fields, candidate.field, source, 'degraded', `${label} duplicates the selected source and was collapsed into one canonical field.`); + } else { + reportField(fields, candidate.field, source, 'unmapped', `${label} conflicts with the higher-priority source and cannot be represented separately.`); + } + } + return selected; +} + +/** + * 从旧 Plugin 元数据、CLI 参数或交互提示中确定规范工程元数据。 + * + * @param scan Legacy Scanner 结果。 + * @param options 迁移 CLI 选项。 + * @returns 已验证名称、版本、描述和可选展示名称。 + */ +export async function metadataFor(scan: ScanResult, options: MigrationOptions, items: MigrationItem[]): Promise { + /** 仅 Plugin/Marketplace 扫描结果携带的旧 Plugin 元数据。 */ + const plugin = 'meta' in scan ? scan as PluginScanResult : undefined; + /** Plugin 元数据来自清单;Project 的必填值来自 CLI 并以来源根表示。 */ + const source = plugin?.metadataSource ?? '.'; + /** 顶层元数据全部已发现字段的保真记录。 */ + const fields: MigrationFieldDraft[] = []; + /** 只有普通对象形态的 Marketplace interface 才能安全枚举回退字段。 */ + const pluginInterface = isRecord(plugin?.meta.interface) ? plugin.meta.interface : undefined; + if (plugin?.meta.interface !== undefined && pluginInterface === undefined) + reportField(fields, 'interface', source, 'unmapped', 'Marketplace interface was not an object.'); + + /** CLI 或旧元数据提供的原始名称候选。 */ + let rawName: unknown = options.name ?? plugin?.meta.name; + if (rawName === undefined && process.stdin.isTTY) + rawName = await input({ message: 'Plugin name', default: safeId(path.basename(scan.rootDir)) }); + if (rawName === undefined) + throw new Error('Migration requires plugin name and description; pass --name and --description in non-interactive mode.'); + if (options.name !== undefined && !ID_PATTERN.test(options.name)) + throw new Error('Migration plugin name must be lowercase kebab-case.'); + /** 最终名称;旧名称可以安全规范化,显式 CLI 名称仍保持严格输入边界。 */ + const name = typeof rawName === 'string' && ID_PATTERN.test(rawName) + ? rawName + : safeId(typeof rawName === 'string' ? rawName : path.basename(scan.rootDir)); + reportField(fields, 'name', source, + typeof rawName === 'string' && ID_PATTERN.test(rawName) && (options.name === undefined || plugin?.meta.name === undefined || plugin.meta.name === rawName) + ? 'mapped' + : typeof rawName === 'string' ? 'degraded' : 'unmapped', + typeof rawName === 'string' && ID_PATTERN.test(rawName) + ? options.name !== undefined && plugin?.meta.name !== undefined && plugin.meta.name !== rawName + ? 'Explicit migration name overrides a different legacy identity.' + : 'Plugin identity maps to top-level config name.' + : typeof rawName === 'string' + ? 'Legacy identity required lowercase kebab-case normalization.' + : 'Invalid legacy identity required a directory-name fallback.'); + + /** 旧根描述及两个 Marketplace interface 回退字段。 */ + const descriptionCandidates: readonly MetadataCandidate[] = [ + { field: 'description', value: plugin?.meta.description, fallback: false }, + { field: 'interface.shortDescription', value: pluginInterface?.shortDescription, fallback: true }, + { field: 'interface.longDescription', value: pluginInterface?.longDescription, fallback: true }, + ]; + /** 未提供 CLI 覆盖时由旧字段优先级选出的描述。 */ + const legacyDescription = options.description === undefined + ? selectMetadataText(fields, source, descriptionCandidates, 'Description') + : undefined; + /** CLI 描述也按 Core 规则规范化,不允许空白字符串绕过。 */ + let description = normalizedText(options.description) ?? legacyDescription; + if (options.description !== undefined) { + if (description === undefined) + throw new Error('Migration description must be a non-empty string.'); + /** candidate 表示被显式 CLI 描述取代、但仍必须报告的旧来源字段。 */ + for (const candidate of descriptionCandidates) { + if (candidate.value === undefined) + continue; + /** 旧描述的规范化文本,用于区分无效输入与有意覆盖。 */ + const value = normalizedText(candidate.value); + reportField(fields, candidate.field, source, value === undefined ? 'unmapped' : 'degraded', value === undefined + ? 'Description did not satisfy the canonical metadata contract.' + : 'Explicit migration description superseded this legacy description source.'); + } + if (!plugin) + reportField(fields, 'description', source, description === options.description ? 'mapped' : 'degraded', description === options.description + ? 'Explicit description maps to top-level config description.' + : 'Explicit description required whitespace normalization.'); + } + if (description === undefined && process.stdin.isTTY) + description = normalizedText(await input({ message: 'Plugin description' })); + if (description === undefined) + throw new Error('Migration requires plugin name and description; pass --name and --description in non-interactive mode.'); + + /** npm SemVer 解析器与 Core 使用同一完整版本规则,包括 build metadata。 */ + const rawVersion = plugin?.meta.version as unknown; + /** 合法旧版本或明确记录降级后的稳定迁移默认版本。 */ + const version = typeof rawVersion === 'string' && semver.valid(rawVersion) ? rawVersion : '0.1.0'; + if (rawVersion !== undefined) { + reportField(fields, 'version', source, version === rawVersion ? 'mapped' : 'degraded', version === rawVersion + ? 'Semantic version maps directly to top-level config version.' + : 'Invalid legacy version required the 0.1.0 fallback.'); + } else { + reportField(fields, 'version', source, 'degraded', 'Missing legacy version required the 0.1.0 migration default.'); + } + + /** 展示名称优先保留根字段,Marketplace interface 只提供显式降级回退。 */ + const displayName = selectMetadataText(fields, source, [ + { field: 'displayName', value: plugin?.meta.displayName, fallback: false }, + { field: 'interface.displayName', value: pluginInterface?.displayName, fallback: true }, + ], 'Display name'); + + /** 旧 author 可能来自未经 Schema 校验的任意 JSON 值。 */ + const rawAuthor = plugin?.meta.author as unknown; + /** 只有根 author.name 合法时才允许组合其 email/url。 */ + const authorRecord = isRecord(rawAuthor) ? rawAuthor : undefined; + /** 根作者名称去空白后的候选。 */ + const rootAuthorName = normalizedText(authorRecord?.name); + /** Marketplace 展示层开发者名称只作为作者回退。 */ + const developerName = normalizedText(pluginInterface?.developerName); + /** 最终统一作者元数据。 */ + let author: PluginMetadata['author']; + if (rawAuthor !== undefined && authorRecord === undefined) + reportField(fields, 'author', source, 'unmapped', 'Author was not an object.'); + if (authorRecord !== undefined) { + if (authorRecord.name === undefined || rootAuthorName === undefined) { + reportField(fields, 'author.name', source, 'unmapped', 'Author name was not a non-empty string.'); + } else { + reportField(fields, 'author.name', source, rootAuthorName === authorRecord.name ? 'mapped' : 'degraded', rootAuthorName === authorRecord.name + ? 'Author name maps to top-level config author.name.' + : 'Author name required whitespace normalization.'); + } + /** 合法根身份下可以独立恢复的 email 与 URL。 */ + const authorDetails: { email?: string; url?: string } = {}; + for (const field of ['email', 'url'] as const) { + /** 当前作者详情字段未经验证的原始值。 */ + const rawValue = authorRecord[field]; + if (rawValue === undefined) + continue; + /** 去空白后的 email 或 URL。 */ + const value = normalizedText(rawValue); + /** 字段自身合法且具有可组合的作者身份时才写入。 */ + const valid = rootAuthorName !== undefined && value !== undefined + && (field === 'email' ? EMAIL_PATTERN.test(value) : isHttpUrl(value)); + if (valid) { + authorDetails[field] = value; + reportField(fields, `author.${field}`, source, value === rawValue ? 'mapped' : 'degraded', value === rawValue + ? `Author ${field} maps to top-level config author.${field}.` + : `Author ${field} required whitespace normalization.`); + } else { + reportField(fields, `author.${field}`, source, 'unmapped', `Author ${field} did not satisfy the canonical metadata contract.`); + } + } + /** key 表示旧 author 中当前无法识别的额外字段。 */ + for (const key of Object.keys(authorRecord).sort(compareCodeUnits)) { + if (!['name', 'email', 'url'].includes(key)) + reportField(fields, `author.${key}`, source, 'unmapped', 'Unknown author field has no canonical mapping.'); + } + if (rootAuthorName !== undefined) + author = { name: rootAuthorName, ...authorDetails }; + } + if (pluginInterface?.developerName !== undefined) { + if (developerName === undefined) { + reportField(fields, 'interface.developerName', source, 'unmapped', 'Developer name was not a non-empty string.'); + } else if (author === undefined) { + author = { name: developerName }; + reportField(fields, 'interface.developerName', source, 'degraded', 'Developer name was used as the fallback canonical author.'); + } else if (author.name === developerName) { + reportField(fields, 'interface.developerName', source, 'degraded', 'Developer name duplicates author.name and was collapsed.'); + } else { + reportField(fields, 'interface.developerName', source, 'unmapped', 'Developer name conflicts with author.name and cannot be represented separately.'); + } + } + + /** URL 字段均按绝对 HTTP(S) 规则验证,interface website 只能降级回退。 */ + const homepage = selectMetadataText(fields, source, [ + { field: 'homepage', value: plugin?.meta.homepage, fallback: false }, + { field: 'interface.websiteURL', value: pluginInterface?.websiteURL, fallback: true }, + ], 'Homepage', isHttpUrl); + /** Repository 没有 interface 回退来源。 */ + const repository = selectMetadataText(fields, source, [ + { field: 'repository', value: plugin?.meta.repository, fallback: false }, + ], 'Repository', isHttpUrl); + /** License 使用真实 SPDX Parser,不以非空字符串冒充合法表达式。 */ + const license = selectMetadataText(fields, source, [ + { field: 'license', value: plugin?.meta.license, fallback: false }, + ], 'License', isSpdxExpression); + + /** Keywords 允许去空白和去重,但任何这种规范化都必须 degraded。 */ + const rawKeywords = plugin?.meta.keywords as unknown; + /** 只有结构有效时才写入配置的规范 keyword 列表。 */ + let keywords: readonly string[] | undefined; + if (rawKeywords !== undefined) { + if (!Array.isArray(rawKeywords) || rawKeywords.some(keyword => normalizedText(keyword) === undefined)) { + reportField(fields, 'keywords', source, 'unmapped', 'Keywords must be an array of non-empty strings.'); + } else { + /** 保持首次出现顺序的规范 keyword。 */ + const normalizedKeywords = rawKeywords.map(keyword => normalizedText(keyword)!); + /** 去重后的规范 keyword 数组。 */ + const uniqueKeywords = [...new Set(normalizedKeywords)]; + /** 去空白或重复折叠都会改变旧字段表示。 */ + const changed = uniqueKeywords.length !== normalizedKeywords.length + || normalizedKeywords.some((keyword, index) => keyword !== rawKeywords[index]); + keywords = uniqueKeywords; + reportField(fields, 'keywords', source, changed ? 'degraded' : 'mapped', changed + ? 'Keywords required whitespace normalization or duplicate removal.' + : 'Keywords map directly to the top-level config field.'); + } + } + + if (plugin?.meta.category !== undefined) + reportField(fields, 'category', source, 'unmapped', 'Platform-neutral metadata has no category field; configure it on a Platform factory.'); + if (plugin?.meta.apps !== undefined) + reportField(fields, 'apps', source, 'unmapped', 'Legacy apps are outside the acplugin 1.0 component contract.'); + /** field 表示当前没有统一元数据或安全自动映射的旧 interface 字段。 */ + for (const field of Object.keys(pluginInterface ?? {}).sort(compareCodeUnits)) { + if (!['displayName', 'shortDescription', 'longDescription', 'developerName', 'websiteURL'].includes(field)) + reportField(fields, `interface.${field}`, source, 'unmapped', 'The Marketplace interface field requires explicit Platform configuration.'); + } + /** 只写入通过逐字段校验的元数据,避免最终 Pipeline 退化为无字段信息的通用失败。 */ + const metadata: PluginMetadata = { + name, + version, + description, + ...(displayName === undefined ? {} : { displayName }), + ...(author === undefined ? {} : { author }), + ...(homepage === undefined ? {} : { homepage }), + ...(repository === undefined ? {} : { repository }), + ...(license === undefined ? {} : { license }), + keywords: keywords ?? [], + }; + items.push(migrationItem({ kind: 'metadata', id: name, source, destination: 'acplugin.config.ts' }, fields)); + return metadata; +} diff --git a/packages/acplugin/src/migration/types.ts b/packages/acplugin/src/migration/types.ts new file mode 100644 index 0000000..24de2b2 --- /dev/null +++ b/packages/acplugin/src/migration/types.ts @@ -0,0 +1,55 @@ +import type { Diagnostic } from '@acplugin/core'; + +/** 控制旧 Claude 工程、Plugin 或 Marketplace 到规范工程的迁移。 */ +export interface MigrationOptions { + cwd?: string; + source: string; + destination?: string; + subPath?: string; + plugin?: string; + all?: boolean; + name?: string; + description?: string; + dryRun?: boolean; + strict?: boolean; +} + +/** 单项旧资源的无损迁移、降级、未映射或跳过结论。 */ +export type MigrationOutcome = 'migrated' | 'degraded' | 'unmapped' | 'skipped'; + +/** 单个旧字段到规范字段的精确保真结论。 */ +export type MigrationFieldOutcome = 'mapped' | 'degraded' | 'unmapped'; + +/** 迁移报告中一个字段的来源、去向和脱敏结论。 */ +export interface MigrationField { + readonly field: string; + readonly source: string; + readonly destination: string; + readonly outcome: MigrationFieldOutcome; + readonly reason: string; +} + +/** 写入资源前暂存的字段结论;省略目标时才继承资源目标。 */ +export type MigrationFieldDraft = Omit & { readonly destination?: string }; + +/** 迁移报告中一项旧资源的处理结果与路径映射。 */ +export interface MigrationItem { + readonly kind: string; + readonly id: string; + readonly outcome: MigrationOutcome; + readonly source?: string; + readonly destination?: string; + readonly message?: string; + readonly fields: readonly MigrationField[]; +} + +/** `acplugin migrate` 返回并持久化的稳定机器可读报告。 */ +export interface MigrationReport { + readonly schemaVersion: '1'; + readonly sourceType: 'project' | 'plugin' | 'marketplace'; + readonly projects: readonly string[]; + readonly items: readonly MigrationItem[]; + readonly diagnostics: readonly Diagnostic[]; + readonly success: boolean; + readonly dryRun: boolean; +} diff --git a/packages/acplugin/src/migration/validation.ts b/packages/acplugin/src/migration/validation.ts new file mode 100644 index 0000000..8fda1a9 --- /dev/null +++ b/packages/acplugin/src/migration/validation.ts @@ -0,0 +1,240 @@ +/** Migration 生成工程的正式 Core lifecycle 验证适配。 */ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { + defineConfig, + runProject, +} from '../index.js'; +import { + defineExtension, + definePlatform, + stableJson, + type Diagnostic, + type PluginMetadata, +} from '@acplugin/core/integration'; +import { copyText } from './writers/shared.js'; + +/** 只在生成工程验证期间向临时 ESM 代理暴露真实公开 API 的全局键。 */ +const MIGRATION_VALIDATION_API = Symbol.for('tokenroll.acplugin.migration-validation-api'); + +/** 并发 Migration 共享同一组不可变公开 API 时用于延迟删除全局桥接。 */ +let activeValidationProxies = 0; + +/** + * 校验 Migration 生成的 plain MCP descriptor。 + * + * 这不是正式 MCP Extension 的替代实现;它只证明 Migration 自己写出的 TypeScript + * 可以由 Core Module Service 执行,正式语义仍由生成工程安装的官方 Extension 校验。 + * + * @param definition Migration 生成源码提交的远程 HTTP 描述。 + */ +function validateMigrationMcpServer(definition: unknown): void { + if (definition === null || typeof definition !== 'object' || Array.isArray(definition)) + throw new TypeError('Migration MCP descriptor must export an object.'); + /** 原型约束阻止 Migration 产物借助类实例携带隐藏行为。 */ + const prototype = Object.getPrototypeOf(definition); + if (prototype !== Object.prototype && prototype !== null) + throw new TypeError('Migration MCP descriptor must export a plain object.'); + /** descriptor 的最小安全字段视图。 */ + const candidate = definition as Record; + if (Object.getOwnPropertySymbols(candidate).length > 0 + || Object.values(Object.getOwnPropertyDescriptors(candidate)).some(descriptor => !('value' in descriptor))) + throw new TypeError('Migration MCP descriptor must not use symbols or accessors.'); + if (candidate.transport !== 'http' || typeof candidate.url !== 'string') + throw new TypeError('Migration MCP descriptor must use the remote HTTP transport.'); + /** Migration 只会自动生成无凭据的 HTTPS endpoint。 */ + const endpoint = new URL(candidate.url); + if (endpoint.protocol !== 'https:' || endpoint.username || endpoint.password) + throw new TypeError('Migration MCP descriptor must use a credential-free HTTPS URL.'); +} + +/** Migration 提交前验证使用的无产物 Platform,不包含任何官方 Platform 逻辑。 */ +const migrationValidationPlatform = definePlatform({ + // Migration may preserve verified Claude-specific fields, so Scanner must see the target ID. + id: 'claude-code', + apiVersion: '1', + deliveryType: 'plugin', + /** Migration 验证使用完整 v2 Session,但不实现任何官方 Platform 转换。 */ + createSession: () => ({ + /** 只声明 Scanner 已接受的 Component 与 metadata,不产生候选 Asset。 */ + createPackage: ({ project }) => ({ + documents: [], + assets: [], + compatibility: [...project.commands, ...project.skills, ...project.agents].map(component => ({ + subject: `${component.kind}:${component.id}`, + capability: 'component', + level: 'native' as const, + reason: 'The migration validation Platform accepts canonical resources.', + })), + metadata: [ + 'name', 'version', 'description', + ...(project.metadata.displayName === undefined ? [] : ['displayName']), + ...(project.metadata.author === undefined + ? [] + : [ + 'author.name', + ...(project.metadata.author.email === undefined ? [] : ['author.email']), + ...(project.metadata.author.url === undefined ? [] : ['author.url']), + ]), + ...(project.metadata.homepage === undefined ? [] : ['homepage']), + ...(project.metadata.repository === undefined ? [] : ['repository']), + ...(project.metadata.license === undefined ? [] : ['license']), + ...(project.metadata.keywords.length === 0 ? [] : ['keywords']), + ].map(field => ({ + field, + disposition: 'emitted' as const, + output: `manifest/${field.replaceAll('.', '/')}`, + reason: 'The migration validation Platform accepts this metadata field.', + })), + }), + /** 使用固定主 Package 身份完成正式 lifecycle。 */ + finalizePackage: () => ({ id: 'plugin', type: 'plugin' }), + /** Migration 私有 Platform 没有额外候选格式规则。 */ + validatePackage: () => undefined, + }), +}); + +/** Migration 提交前执行自己生成的 MCP descriptor,并声明 mcp root 所有权。 */ +const migrationValidationMcp = defineExtension({ + id: 'migration-validation-mcp', + apiVersion: '1', + resourceRoots: ['mcp'], + /** 每轮验证创建隔离的 descriptor Module Session。 */ + createSession: () => ({ + /** 通过 v2 SourceRef/ModuleService fresh evaluate 每个生成 descriptor。 */ + async discover({ roots, sources, modules }) { + /** 配置声明的 mcp root 是当前 Extension 唯一可读来源。 */ + const root = roots.mcp; + if (root === undefined) + return undefined; + /** mcp root 只接受一层稳定 Server 目录。 */ + const entries = await sources.list(root); + /** count 只用于证明所有 descriptor 均已通过执行验证。 */ + let count = 0; + for (const entry of entries) { + if (entry.type !== 'directory') + throw new TypeError('Migration MCP entries must be directories.'); + /** 每个 Server 目录的固定作者入口。 */ + const descriptor = await sources.file(entry.directory, 'mcp.ts'); + /** 默认导出必须跨越正式 Module Host 数据边界。 */ + const value = await modules.loadDefault({ id: entry.name, entry: descriptor }); + validateMigrationMcpServer(value); + count += 1; + } + return Object.freeze({ count }); + }, + /** Migration descriptor 没有 Platform delivery subject,只验证模块本身。 */ + validate: (_context, discovered) => ({ state: discovered, subjects: [] }), + /** 无 Contributor 时 Core 会跳过 build;该方法只满足完整 Session contract。 */ + build: (_context, validated) => ({ state: validated }), + contributors: [], + }), +}); + +/** 临时代理读取的主包与 Migration 私有验证 API。 */ +interface MigrationValidationApi { + /** 生成配置使用的公开恒等辅助函数。 */ + readonly defineConfig: typeof defineConfig; + /** 不生成产物、只驱动正式 Core Scanner 的 Migration 私有 Platform。 */ + readonly migrationValidationPlatform: typeof migrationValidationPlatform; + /** 只通过 v2 Module Service 验证 Migration 生成 MCP descriptor 的私有 Extension。 */ + readonly migrationValidationMcp: typeof migrationValidationMcp; +} + +/** + * 用正式公开 API 加载并验证刚生成、尚未提交的规范工程。 + * + * 生成工程尚未安装 package.json 依赖,因此验证期间创建只存在于 stage 的 ESM 代理。 + * 代理不实现任何规则,只把配置和 descriptor 导向当前进程已经加载的真实主包与 MCP + * Extension;验证后整个 node_modules 会在提交前删除。 + * + * @param outputRoot 单个迁移后规范工程的阶段目录。 + * @param usesMcp 工程是否需要正式 MCP Extension 参与 discover/validate。 + * @returns 公开 runProject() 返回的完整结构化诊断。 + */ +export async function validateCanonicalProject( + outputRoot: string, + usesMcp: boolean, + metadata: PluginMetadata, +): Promise { + /** 只供本次配置和 descriptor 加载解析包名的临时依赖根。 */ + const nodeModules = path.join(outputRoot, 'node_modules'); + /** 最终生成配置在验证期间由等价元数据的私有验证配置暂时替代。 */ + const configPath = path.join(outputRoot, 'acplugin.config.ts'); + /** 验证后必须恢复的最终用户配置文本。 */ + const generatedConfig = await fs.readFile(configPath, 'utf8'); + /** 全局桥接不暴露 Core Registry,也不引用或内联任何官方集成实现。 */ + const api: MigrationValidationApi = Object.freeze({ + defineConfig, + migrationValidationPlatform, + migrationValidationMcp, + }); + Reflect.set(globalThis, MIGRATION_VALIDATION_API, api); + activeValidationProxies += 1; + try { + /** 临时主包代理由生成的 acplugin.config.ts 正常按包名导入。 */ + const acpluginPackage = path.join(nodeModules, '@tokenroll/acplugin'); + await copyText(path.join(acpluginPackage, 'package.json'), stableJson({ + name: '@tokenroll/acplugin', + version: '1.0.0', + type: 'module', + exports: './index.mjs', + })); + await copyText(path.join(acpluginPackage, 'index.mjs'), ` +const api = globalThis[Symbol.for('tokenroll.acplugin.migration-validation-api')]; +if (!api) throw new Error('Migration validation API is unavailable.'); +export const defineConfig = api.defineConfig; +export const migrationValidationPlatform = api.migrationValidationPlatform; +export const migrationValidationMcp = api.migrationValidationMcp; +`); + if (usesMcp) { + /** 临时 MCP 包只为生成源码中的 type-only import 提供可解析包身份。 */ + const extensionPackage = path.join(nodeModules, '@tokenroll/acplugin-extension-mcp'); + await copyText(path.join(extensionPackage, 'package.json'), stableJson({ + name: '@tokenroll/acplugin-extension-mcp', + version: '1.0.0', + type: 'module', + exports: './index.mjs', + })); + await copyText(path.join(extensionPackage, 'index.mjs'), 'export {};\n'); + } + /** 用相同元数据驱动 Core Scanner;正式 Platform/Extension 在安装依赖后自行验证。 */ + await fs.writeFile(configPath, ` +import { + defineConfig, + migrationValidationMcp, + migrationValidationPlatform, +} from '@tokenroll/acplugin'; + +export default defineConfig({ + ...${stableJson(metadata).trim()}, + extensions: ${usesMcp ? '[migrationValidationMcp]' : '[]'}, + platforms: [migrationValidationPlatform], + build: { strict: false }, +}); +`); + /** 正式配置加载、Scanner、Extension 和全部配置 Platform validate 的公开结果。 */ + const result = await runProject({ + cwd: outputRoot, + command: 'validate', + mode: 'production', + commit: false, + }); + return result.diagnostics; + } catch { + /** 配置执行异常统一收敛为不携带路径、导出值或堆栈的迁移诊断。 */ + const diagnostics: readonly Diagnostic[] = Object.freeze([{ + code: 'MIGRATION_PROJECT_VALIDATION_FAILED', + severity: 'error', + phase: 'validate', + message: 'The generated project could not be loaded and validated through the public API.', + }]); + return diagnostics; + } finally { + await fs.writeFile(configPath, generatedConfig); + await fs.rm(nodeModules, { recursive: true, force: true }); + activeValidationProxies -= 1; + if (activeValidationProxies === 0) + Reflect.deleteProperty(globalThis, MIGRATION_VALIDATION_API); + } +} diff --git a/packages/acplugin/src/migration/writers/components.ts b/packages/acplugin/src/migration/writers/components.ts new file mode 100644 index 0000000..245379e --- /dev/null +++ b/packages/acplugin/src/migration/writers/components.ts @@ -0,0 +1,421 @@ +/** Canonical Skill、Command 与 Agent 的 Migration writer。 */ +import path from 'node:path'; +import type { AgentCapability } from '@acplugin/core'; +import matter from 'gray-matter'; +import type { Agent, Command, Skill } from '../legacy/types.js'; +import { compareCodeUnits, ID_PATTERN, relative } from '../ids.js'; +import type { MigrationFieldDraft, MigrationItem } from '../types.js'; +import { + copyBytes, + copyText, + markdownWithFrontmatter, + migrationItem, + reportField, +} from './shared.js'; + +/** + * 把旧 Skill 及全部辅助文件迁移为规范 Skill 目录。 + * + * @param skill Legacy Scanner 读取的 Skill。 + * @param id 已在 Skill namespace 中完成冲突消歧的最终 ID。 + * @param projectRoot 旧工程根目录。 + * @param outputRoot 新规范工程的阶段目录。 + * @param items 共享迁移报告条目数组。 + * @returns 可与其他资源并行等待的文件写入任务。 + */ +export function migrateSkill(skill: Skill, id: string, projectRoot: string, outputRoot: string, items: MigrationItem[]): Promise[] { + /** 当前 Skill 报告使用的稳定来源路径。 */ + const source = relative(projectRoot, skill.sourcePath); + /** 当前 Skill 全部已发现字段的保真记录。 */ + const fields: MigrationFieldDraft[] = []; + reportField(fields, 'name', source, ID_PATTERN.test(skill.dirName) && skill.dirName === id ? 'mapped' : 'degraded', ID_PATTERN.test(skill.dirName) && skill.dirName === id + ? 'Directory identity maps directly to the canonical Skill ID.' + : 'Skill identity required lowercase kebab-case normalization or a deterministic collision suffix.'); + if (skill.frontmatter.name !== undefined) { + reportField(fields, 'frontmatter.name', source, skill.frontmatter.name === id ? 'mapped' : 'degraded', skill.frontmatter.name === id + ? 'Frontmatter identity agrees with the canonical directory identity.' + : 'Frontmatter name differs from the canonical directory identity.'); + } + /** 优先保留旧描述,否则生成明确的迁移回退描述。 */ + const description = skill.frontmatter.description || skill.frontmatter.when_to_use || `Migrated Skill ${id}.`; + if (skill.frontmatter.description) { + reportField(fields, 'description', source, 'mapped', 'Description maps directly to canonical Skill frontmatter.'); + } else if (skill.frontmatter.when_to_use) { + reportField(fields, 'when_to_use', source, 'mapped', 'when_to_use maps to the canonical Skill description.'); + } else { + reportField(fields, 'description', source, 'degraded', 'Description required a generated fallback.'); + } + /** 旧 Skill 未经 Schema 校验的用户调用开关。 */ + const rawUserInvocation = skill.frontmatter['user-invocable']; + /** 旧 Skill 未经 Schema 校验的模型禁用开关。 */ + const rawModelDisabled = skill.frontmatter['disable-model-invocation']; + /** 无效或缺失的用户开关回退到旧平台默认 true。 */ + let user = typeof rawUserInvocation === 'boolean' ? rawUserInvocation : true; + /** 无效或缺失的模型开关回退到旧平台默认可调用。 */ + const model = typeof rawModelDisabled === 'boolean' ? !rawModelDisabled : true; + if (rawUserInvocation !== undefined) { + if (typeof rawUserInvocation !== 'boolean') { + reportField(fields, 'user-invocable', source, 'unmapped', 'user-invocable was not boolean.'); + } else if (!user && !model) { + user = true; + reportField(fields, 'user-invocable', source, 'degraded', 'Both invocation paths were disabled; canonical format required enabling user invocation.'); + } else { + reportField(fields, 'user-invocable', source, 'mapped', 'user-invocable maps to canonical invocation.user.'); + } + } + if (rawModelDisabled !== undefined) { + reportField(fields, 'disable-model-invocation', source, typeof rawModelDisabled === 'boolean' ? 'mapped' : 'unmapped', + typeof rawModelDisabled === 'boolean' + ? 'disable-model-invocation maps inversely to canonical invocation.model.' + : 'disable-model-invocation was not boolean.'); + } + if (rawUserInvocation === undefined && rawModelDisabled === undefined) + reportField(fields, 'invocation', source, 'mapped', 'Legacy invocation defaults map to canonical user/model policy.'); + /** Claude Code 专属字段在规范 Skill 中的精确保留映射。 */ + const claudeFields: Record = {}; + /** 旧 allowed-tools 的稳定数组表示。 */ + const allowedTools = legacyStringList(skill.frontmatter['allowed-tools']); + if (skill.frontmatter['allowed-tools'] !== undefined) { + if (allowedTools) { + claudeFields.allowedTools = allowedTools; + reportField(fields, 'allowed-tools', source, 'mapped', 'Tool restrictions map to the Claude Code Platform field.'); + } else { + reportField(fields, 'allowed-tools', source, 'unmapped', 'allowed-tools was not a valid non-empty tool list.'); + } + } + /** field 表示当前可精确进入 Claude Code Platform 字段的普通字符串。 */ + for (const field of ['model', 'agent'] as const) { + /** 旧 Frontmatter 中当前字符串字段。 */ + const value = skill.frontmatter[field]; + if (value !== undefined) { + if (typeof value === 'string' && value.trim()) { + claudeFields[field] = value.trim(); + reportField(fields, field, source, 'mapped', `${field} maps to the Claude Code Platform field.`); + } else { + reportField(fields, field, source, 'unmapped', `${field} was not a non-empty string.`); + } + } + } + if (skill.frontmatter.context !== undefined) { + if (skill.frontmatter.context === 'fork') { + claudeFields.context = 'fork'; + reportField(fields, 'context', source, 'mapped', 'fork maps to the verified Claude Code context field.'); + } else { + reportField(fields, 'context', source, 'unmapped', 'Only the verified Claude Code fork context can be preserved.'); + } + } + reportUnknownFields(fields, source, skill.frontmatter as unknown as Readonly>, new Set([ + 'name', 'description', 'when_to_use', 'user-invocable', 'disable-model-invocation', 'allowed-tools', 'model', 'context', 'agent', + ])); + /** 规范 Skill 主文件的工程相对路径。 */ + const destination = `src/skills/${id}/SKILL.md`; + reportField(fields, 'body', source, 'mapped', 'Markdown body maps without model-visible annotations.'); + for (const auxiliary of skill.auxFiles) { + reportField( + fields, + `auxiliary:${auxiliary.relativePath}`, + relative(projectRoot, auxiliary.sourcePath), + 'mapped', + 'Auxiliary file is copied byte-for-byte with the Skill.', + `src/skills/${id}/${auxiliary.relativePath.split(path.sep).join('/')}`, + ); + } + items.push(migrationItem({ kind: 'skill', id, source, destination }, fields)); + /** 主文件及后续辅助文件的并行写入任务。 */ + const writes = [copyText(path.join(outputRoot, destination), markdownWithFrontmatter({ + description, + invocation: { user, model }, + ...(Object.keys(claudeFields).length === 0 ? {} : { platforms: { 'claude-code': claudeFields } }), + }, skill.body))]; + for (const auxiliary of skill.auxFiles) + writes.push(copyBytes(auxiliary.sourcePath, path.join(outputRoot, 'src/skills', id, auxiliary.relativePath))); + return writes; +} + +/** + * 把逗号分隔字符串或字符串数组转换为去重的非空字段列表。 + * + * @param value Legacy Frontmatter 中未经验证的工具或 Skill 列表。 + * @returns 有效列表;字段缺失或无效时返回 undefined。 + */ +function legacyStringList(value: unknown): string[] | undefined { + if (value === undefined) + return undefined; + /** 字符串使用 Claude 旧格式的逗号分隔规则,数组保持原声明顺序。 */ + const values = typeof value === 'string' + ? value.split(',').map(item => item.trim()).filter(Boolean) + : Array.isArray(value) ? value : []; + if (values.length === 0 || values.some(item => typeof item !== 'string' || item.trim() === '')) + return undefined; + /** 去重后的列表,避免生成的新 Platform 字段无法通过严格 Schema。 */ + return [...new Set(values as string[])]; +} + +/** + * 把未列入迁移白名单且实际存在的旧 Frontmatter 字段逐项报告为 unmapped。 + * + * @param fields 当前资源累计的字段级结论。 + * @param source 旧资源相对路径。 + * @param data Legacy Scanner 的宽松 Frontmatter。 + * @param allowed 当前资源可以自动迁移的字段集合。 + */ +function reportUnknownFields( + fields: MigrationFieldDraft[], + source: string, + data: Readonly>, + allowed: ReadonlySet, +): void { + /** field 表示当前需要进入人工迁移流程的旧字段。 */ + for (const field of Object.keys(data).sort(compareCodeUnits)) { + if (!allowed.has(field)) + reportField(fields, field, source, 'unmapped', 'The legacy field has no canonical or verified Platform mapping.'); + } +} + +/** + * 把旧 Command Markdown 迁移为规范 Command,并转换参数占位符。 + * + * @param command Legacy Scanner 读取的 Command。 + * @param id 已在 Command namespace 中完成冲突消歧的最终 ID。 + * @param projectRoot 旧工程根目录。 + * @param outputRoot 新规范工程的阶段目录。 + * @param items 共享迁移报告条目数组。 + * @returns Command 文件写入任务。 + */ +export function migrateCommand(command: Command, id: string, projectRoot: string, outputRoot: string, items: MigrationItem[]): Promise { + /** 当前 Command 报告使用的稳定来源路径。 */ + const source = relative(projectRoot, command.sourcePath); + /** 当前 Command 全部已发现字段的保真记录。 */ + const fields: MigrationFieldDraft[] = []; + reportField(fields, 'name', source, ID_PATTERN.test(command.name) && command.name === id ? 'mapped' : 'degraded', ID_PATTERN.test(command.name) && command.name === id + ? 'Filename identity maps directly to the canonical Command ID.' + : 'Command identity required lowercase kebab-case normalization or a deterministic collision suffix.'); + /** 解析 Frontmatter 后保留的 Command 正文。 */ + let body = command.content; + /** 优先读取旧描述,否则使用明确的迁移回退值。 */ + let description = `Migrated Command ${id}.`; + /** 迁移后写入规范 Frontmatter 的字段集合。 */ + const frontmatter: Record = {}; + try { + /** 旧 Command 的 Frontmatter 与正文解析结果。 */ + const parsed = matter(command.content); + body = parsed.content.trim(); + if (typeof parsed.data.description === 'string' && parsed.data.description.trim()) { + description = parsed.data.description.trim(); + reportField(fields, 'description', source, 'mapped', 'Description maps directly to canonical Command frontmatter.'); + } else { + reportField(fields, 'description', source, 'degraded', 'Description required a generated fallback.'); + } + /** Claude 原生拼写优先于旧工具曾使用的 camelCase 拼写。 */ + const nativeHint = parsed.data['argument-hint']; + /** camelCase 拼写仍是需要保真的合法 Legacy 输入。 */ + const camelHint = parsed.data.argumentHint; + /** 两种来源分别规范化,避免 truthy 非字符串绕过字段报告。 */ + const normalizedNative = typeof nativeHint === 'string' && nativeHint.trim() ? nativeHint.trim() : undefined; + /** camelCase 来源的非空字符串值。 */ + const normalizedCamel = typeof camelHint === 'string' && camelHint.trim() ? camelHint.trim() : undefined; + if (normalizedNative !== undefined) { + frontmatter.argumentHint = normalizedNative; + reportField(fields, 'argument-hint', source, normalizedNative === nativeHint ? 'mapped' : 'degraded', normalizedNative === nativeHint + ? 'Claude-native argument-hint maps to canonical argumentHint.' + : 'Claude-native argument-hint required whitespace normalization.'); + } else if (nativeHint !== undefined) { + reportField(fields, 'argument-hint', source, 'unmapped', 'argument-hint was not a non-empty string.'); + } + if (normalizedCamel !== undefined && normalizedNative === undefined) { + frontmatter.argumentHint = normalizedCamel; + reportField(fields, 'argumentHint', source, normalizedCamel === camelHint ? 'mapped' : 'degraded', normalizedCamel === camelHint + ? 'Legacy camelCase argumentHint maps directly to canonical argumentHint.' + : 'Legacy camelCase argumentHint required whitespace normalization.'); + } else if (normalizedCamel !== undefined && normalizedNative !== undefined) { + reportField(fields, 'argumentHint', source, normalizedCamel === normalizedNative ? 'mapped' : 'degraded', normalizedCamel === normalizedNative + ? 'Both legacy argument hint spellings agree with the canonical value.' + : 'Conflicting argument hints were degraded to the Claude-native argument-hint value.'); + } else if (camelHint !== undefined) { + reportField(fields, 'argumentHint', source, 'unmapped', 'argumentHint was not a non-empty string.'); + } + /** Claude Code 专属 Command 字段。 */ + const claudeFields: Record = {}; + /** 旧 allowed-tools 的稳定数组表示。 */ + const allowedTools = legacyStringList(parsed.data['allowed-tools']); + if (parsed.data['allowed-tools'] !== undefined) { + if (allowedTools) { + claudeFields.allowedTools = allowedTools; + reportField(fields, 'allowed-tools', source, 'mapped', 'Tool restrictions map to the Claude Code Platform field.'); + } else { + reportField(fields, 'allowed-tools', source, 'unmapped', 'allowed-tools was not a valid non-empty tool list.'); + } + } + if (parsed.data.model !== undefined) { + if (typeof parsed.data.model === 'string' && parsed.data.model.trim()) { + claudeFields.model = parsed.data.model.trim(); + reportField(fields, 'model', source, 'mapped', 'Model maps to the Claude Code Platform field.'); + } else { + reportField(fields, 'model', source, 'unmapped', 'model was not a non-empty string.'); + } + } + if (Object.keys(claudeFields).length > 0) + frontmatter.platforms = { 'claude-code': claudeFields }; + reportUnknownFields(fields, source, parsed.data, new Set(['description', 'argument-hint', 'argumentHint', 'allowed-tools', 'model'])); + } catch { + reportField(fields, 'frontmatter', source, 'unmapped', 'Frontmatter could not be parsed and requires manual recovery.'); + } + frontmatter.description = description; + body = body.replaceAll('$ARGUMENTS', '{{arguments}}'); + /** 规范 Command 文件的工程相对路径。 */ + const destination = `src/commands/${id}.md`; + reportField(fields, 'body', source, 'mapped', 'Markdown body and argument placeholder map to canonical Command content.'); + items.push(migrationItem({ kind: 'command', id, source, destination }, fields)); + return copyText(path.join(outputRoot, destination), markdownWithFrontmatter(frontmatter, body)); +} + +/** + * 把旧 Claude 模型名称收敛为 Core 可移植模型档位。 + * + * @param value 旧 Agent model 字段。 + * @returns fast、capable 或 inherit。 + */ +function mappedModel(value: string | undefined): 'inherit' | 'fast' | 'capable' { + if (value === 'haiku') + return 'fast'; + if (value === 'sonnet' || value === 'opus') + return 'capable'; + return 'inherit'; +} + +/** + * 从 Claude Code 工具名推导跨平台保守能力集合。 + * + * 精确工具白名单仍保存在 Claude Code Platform 字段中;这里只为其他 Platform 提供可移植近似。 + * + * @param tools 已验证的旧 Claude Code 工具名。 + * @returns 按 Core 固定顺序去重的规范能力。 + */ +function capabilitiesFromTools(tools: readonly string[]): AgentCapability[] { + /** 每个稳定工具对应的最小规范能力。 */ + const mapping: Readonly> = { + Read: 'filesystem:read', + Write: 'filesystem:write', + Edit: 'filesystem:write', + NotebookEdit: 'filesystem:write', + Glob: 'search', + Grep: 'search', + Bash: 'shell', + WebFetch: 'network', + Agent: 'delegate', + Task: 'delegate', + }; + /** 工具列表映射得到的能力集合。 */ + const found = new Set(); + for (const tool of tools) { + // WebSearch 同时依赖发现能力和远程访问,不能压缩成单一 capability。 + if (tool === 'WebSearch') { + found.add('search'); + found.add('network'); + continue; + } + /** 当前 Claude 工具可保守映射出的单一规范能力。 */ + const capability = mapping[tool]; + if (capability !== undefined) + found.add(capability); + } + /** Core 对外采用的固定能力顺序。 */ + const order: readonly AgentCapability[] = ['filesystem:read', 'filesystem:write', 'search', 'shell', 'network', 'delegate']; + return order.filter(capability => found.has(capability)); +} + +/** + * 把旧 Agent Markdown 迁移为规范 Agent,并泛化平台模型名称。 + * + * @param agent Legacy Scanner 读取的 Agent。 + * @param id 已在 Agent namespace 中完成冲突消歧的最终 ID。 + * @param projectRoot 旧工程根目录。 + * @param outputRoot 新规范工程的阶段目录。 + * @param items 共享迁移报告条目数组。 + * @returns Agent 文件写入任务。 + */ +export function migrateAgent(agent: Agent, id: string, projectRoot: string, outputRoot: string, items: MigrationItem[]): Promise { + /** 当前 Agent 报告使用的稳定来源路径。 */ + const source = relative(projectRoot, agent.sourcePath); + /** 当前 Agent 全部已发现字段的保真记录。 */ + const fields: MigrationFieldDraft[] = []; + reportField(fields, 'name', source, ID_PATTERN.test(agent.fileName) && agent.fileName === id ? 'mapped' : 'degraded', ID_PATTERN.test(agent.fileName) && agent.fileName === id + ? 'Filename identity maps directly to the canonical Agent ID.' + : 'Agent identity required lowercase kebab-case normalization or a deterministic collision suffix.'); + if (agent.frontmatter.name !== undefined) { + reportField(fields, 'frontmatter.name', source, agent.frontmatter.name === id ? 'mapped' : 'degraded', agent.frontmatter.name === id + ? 'Frontmatter identity agrees with the canonical filename identity.' + : 'Frontmatter name differs from the canonical filename identity.'); + } + /** 旧描述或明确的迁移回退描述。 */ + const description = agent.frontmatter.description || `Migrated Agent ${id}.`; + reportField(fields, 'description', source, agent.frontmatter.description ? 'mapped' : 'degraded', agent.frontmatter.description + ? 'Description maps directly to canonical Agent frontmatter.' + : 'Description required a generated fallback.'); + /** 旧模型是否属于可映射的已知集合。 */ + const knownModel = agent.frontmatter.model === undefined || ['inherit', 'haiku', 'sonnet', 'opus'].includes(agent.frontmatter.model); + if (agent.frontmatter.model !== undefined) { + reportField(fields, 'model', source, knownModel ? 'mapped' : 'degraded', knownModel + ? 'Known Claude model maps to the canonical model class.' + : 'Unknown model was generalized to inherit.'); + } + /** 可以由 Claude Code Platform 精确保留的 Agent 字段。 */ + const claudeFields: Record = {}; + /** 旧工具白名单及其跨平台保守能力映射。 */ + const tools = legacyStringList(agent.frontmatter.tools); + if (agent.frontmatter.tools !== undefined) { + if (tools) { + claudeFields.tools = tools; + /** 无法推导跨平台 capability 的工具仍会在 Claude Code 字段中精确保留。 */ + const hasPlatformOnlyTool = tools.some(tool => capabilitiesFromTools([tool]).length === 0); + reportField(fields, 'tools', source, hasPlatformOnlyTool ? 'degraded' : 'mapped', hasPlatformOnlyTool + ? 'Tool restrictions are preserved for Claude Code, but at least one tool has no portable capability mapping.' + : 'Tool restrictions map to Claude Code and portable capabilities.'); + } else { + reportField(fields, 'tools', source, 'unmapped', 'tools was not a valid non-empty tool list.'); + } + } + /** 旧工具黑名单仅在 Claude Code Platform 中精确保留。 */ + const disallowedTools = legacyStringList(agent.frontmatter.disallowedTools); + if (agent.frontmatter.disallowedTools !== undefined) { + if (disallowedTools) { + claudeFields.disallowedTools = disallowedTools; + reportField(fields, 'disallowedTools', source, 'mapped', 'Denied tools map to the Claude Code Platform field.'); + } else { + reportField(fields, 'disallowedTools', source, 'unmapped', 'disallowedTools was not a valid non-empty tool list.'); + } + } + /** 字段及其允许值谓词组成的 Claude Code 精确映射表。 */ + const exactFields: readonly [string, unknown, (value: unknown) => boolean][] = [ + ['effort', agent.frontmatter.effort, value => typeof value === 'string' && ['low', 'medium', 'high', 'xhigh', 'max'].includes(value)], + ['maxTurns', agent.frontmatter.maxTurns, value => Number.isInteger(value) && Number(value) > 0], + ['skills', agent.frontmatter.skills, value => legacyStringList(value) !== undefined], + ['memory', agent.frontmatter.memory, value => typeof value === 'string' && ['user', 'project', 'local'].includes(value)], + ['background', agent.frontmatter.background, value => typeof value === 'boolean'], + ['isolation', agent.frontmatter.isolation, value => value === 'worktree'], + ]; + /** [field, value, valid] 表示当前可进入 Claude Code Agent Platform 字段的候选。 */ + for (const [field, value, valid] of exactFields) { + if (value === undefined) + continue; + if (valid(value)) { + claudeFields[field] = field === 'skills' ? legacyStringList(value)! : value; + reportField(fields, field, source, 'mapped', `${field} maps to the verified Claude Code Platform field.`); + } else { + reportField(fields, field, source, 'unmapped', `${field} did not satisfy the current Claude Code field contract.`); + } + } + reportUnknownFields(fields, source, agent.frontmatter as unknown as Readonly>, new Set([ + 'name', 'description', 'tools', 'disallowedTools', 'model', 'effort', 'maxTurns', 'skills', 'memory', 'background', 'isolation', + ])); + /** 规范 Agent 文件的工程相对路径。 */ + const destination = `src/agents/${id}.md`; + reportField(fields, 'body', source, 'mapped', 'Markdown body maps without model-visible migration annotations.'); + items.push(migrationItem({ kind: 'agent', id, source, destination }, fields)); + return copyText(path.join(outputRoot, destination), markdownWithFrontmatter({ + description, + model: mappedModel(agent.frontmatter.model), + capabilities: capabilitiesFromTools(tools ?? []), + ...(Object.keys(claudeFields).length === 0 ? {} : { platforms: { 'claude-code': claudeFields } }), + }, agent.body)); +} diff --git a/packages/acplugin/src/migration/writers/hooks.ts b/packages/acplugin/src/migration/writers/hooks.ts new file mode 100644 index 0000000..d345ae2 --- /dev/null +++ b/packages/acplugin/src/migration/writers/hooks.ts @@ -0,0 +1,103 @@ +/** Legacy Hook 引用的隔离保留逻辑。 */ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { Hooks } from '../legacy/types.js'; +import { compareCodeUnits } from '../ids.js'; +import type { MigrationItem } from '../types.js'; +import { migrationItem } from './shared.js'; + +/** + * 从旧 Hook 命令中提取相对于 Plugin/Project 根目录的文件引用候选。 + * + * @param hooks Legacy Scanner 读取的原始 Hook 配置。 + * @returns 去重并稳定排序的相对路径。 + */ +export function hookReferenceCandidates(hooks: Hooks): string[] { + /** 从环境变量根路径和 `./` 语法提取的引用集合。 */ + const references = new Set(); + for (const matchers of Object.values(hooks)) { + for (const matcher of matchers) { + for (const hook of matcher.hooks) { + if (!hook.command) + continue; + for (const match of hook.command.matchAll(/(?:\$\{(?:CLAUDE_PLUGIN_ROOT|CLAUDE_PROJECT_DIR)\}|\$(?:CLAUDE_PLUGIN_ROOT|CLAUDE_PROJECT_DIR))\/([^\s"'`;|&]+)/g)) + references.add(match[1]!); + for (const match of hook.command.matchAll(/(?:^|[\s"'=])\.\/([^\s"'`;|&]+)/g)) + references.add(match[1]!); + } + } + } + return [...references].sort(compareCodeUnits); +} + +/** + * 递归保留旧 Hook 引用文件,但不把未经类型化迁移的代码加入可发布源码。 + * + * @param sourceRoot 旧工程根目录和路径信任边界。 + * @param relativePath Hook 命令提取出的相对路径。 + * @param outputRoot 新规范工程的阶段目录。 + * @param items 共享迁移报告条目数组。 + */ +export async function copyHookReference( + sourceRoot: string, + relativePath: string, + outputRoot: string, + items: MigrationItem[], +): Promise { + /** 解析后的 Hook 引用绝对路径。 */ + const source = path.resolve(sourceRoot, relativePath); + /** 用于阻止目录逃逸并生成报告的来源相对路径。 */ + const relation = path.relative(sourceRoot, source); + if (relation === '..' || relation.startsWith(`..${path.sep}`) || path.isAbsolute(relation)) { + /** 越界引用只保留脱敏字段结论,不把绝对解析路径写入报告。 */ + const safeSource = relativePath.split(path.sep).join('/'); + items.push(migrationItem({ kind: 'hook-file', id: safeSource }, [{ + field: 'content', source: safeSource, outcome: 'unmapped', + reason: 'Referenced Hook file escapes the source project and was not copied.', + }])); + return; + } + /** 引用文件的 lstat 元数据,用于拒绝符号链接。 */ + let stat: import('node:fs').Stats; + try { + stat = await fs.lstat(source); + } catch { + /** 不存在的引用仍用工程相对路径进入字段报告。 */ + const normalized = relation.split(path.sep).join('/'); + items.push(migrationItem({ kind: 'hook-file', id: relativePath, source: normalized }, [{ + field: 'content', source: normalized, outcome: 'unmapped', + reason: 'Referenced Hook file does not exist and requires manual recovery.', + }])); + return; + } + if (stat.isSymbolicLink()) { + /** 符号链接不解引用,只报告链接自身的相对位置。 */ + const normalized = relation.split(path.sep).join('/'); + items.push(migrationItem({ kind: 'hook-file', id: relativePath, source: normalized }, [{ + field: 'content', source: normalized, outcome: 'unmapped', + reason: 'Referenced Hook symlinks are not copied.', + }])); + return; + } + if (stat.isDirectory()) { + /** 按名称稳定递归的目录项。 */ + const entries = await fs.readdir(source, { withFileTypes: true }); + for (const entry of entries.sort((a, b) => compareCodeUnits(a.name, b.name))) + await copyHookReference(sourceRoot, path.join(relativePath, entry.name), outputRoot, items); + return; + } + if (!stat.isFile()) + return; + /** 报告和未映射目录使用的 POSIX 相对路径。 */ + const normalized = relation.split(path.sep).join('/'); + /** 与可发布源码隔离的 Hook 文件目标路径。 */ + const destination = `.acplugin-migration/unmapped/hook-files/${normalized}`; + /** 未映射文件的绝对写入路径。 */ + const output = path.join(outputRoot, destination); + await fs.mkdir(path.dirname(output), { recursive: true }); + await fs.copyFile(source, output); + items.push(migrationItem({ kind: 'hook-file', id: normalized, source: normalized, destination }, [{ + field: 'content', source: normalized, destination, outcome: 'unmapped', + reason: 'Referenced Hook implementation was preserved for manual typed migration.', + }])); +} diff --git a/packages/acplugin/src/migration/writers/mcp.ts b/packages/acplugin/src/migration/writers/mcp.ts new file mode 100644 index 0000000..58aebc2 --- /dev/null +++ b/packages/acplugin/src/migration/writers/mcp.ts @@ -0,0 +1,97 @@ +/** Legacy MCP 的安全远程映射与脱敏 sidecar writer helper。 */ +import type { MCPServer } from '../legacy/types.js'; + +/** + * 识别仅包含 `${ENV_NAME}` 的安全环境变量引用。 + * + * @param value 旧配置中的字符串值。 + * @returns 环境变量名称;包含字面量或无效语法时返回 undefined。 + */ +function environmentReference(value: string): string | undefined { + /** 完整匹配环境变量插值的捕获结果。 */ + const match = value.match(/^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/); + return match?.[1]; +} + +/** + * 尝试把无凭据、HTTPS 且只引用环境变量的旧远程 MCP 转为类型化定义源码。 + * + * @param server Legacy Scanner 读取的 MCP Server。 + * @returns 可安全自动迁移的 `mcp.ts` 源码,否则返回 undefined 并转入未映射区。 + */ +export function remoteMcpSource(server: MCPServer): string | undefined { + if (!server.url || !['http', 'streamable-http', undefined].includes(server.type)) + return undefined; + /** 完成语法与敏感 URL 组件检查的远程端点。 */ + let endpoint: URL; + try { + endpoint = new URL(server.url); + } catch { + return undefined; + } + if (endpoint.protocol !== 'https:' || endpoint.username || endpoint.password || endpoint.search || endpoint.hash) + return undefined; + /** 仅保留环境变量引用的非认证 Header。 */ + const headers: Record = {}; + /** 从 Authorization Header 提取的可选 Bearer 环境变量策略。 */ + let auth: Record | undefined; + for (const [name, value] of Object.entries(server.headers ?? {})) { + /** Authorization Header 是否是可安全迁移的 Bearer 环境变量引用。 */ + const bearer = name.toLowerCase() === 'authorization' && value.match(/^Bearer \$\{([A-Za-z_][A-Za-z0-9_]*)\}$/); + if (bearer) { + auth = { type: 'bearer', env: bearer[1]! }; + continue; + } + /** 普通 Header 值中唯一允许保留的环境变量名。 */ + const env = environmentReference(value); + if (!env) + return undefined; + headers[name] = { env }; + } + /** 按稳定格式组装的类型化 MCP 描述源码行。 */ + const descriptor = [ + `import type { McpServer } from '@tokenroll/acplugin-extension-mcp';`, + '', + 'export default {', + ` transport: 'http',`, + ` url: ${JSON.stringify(endpoint.href)},`, + ...(auth ? [` auth: ${JSON.stringify(auth)},`] : []), + ...(Object.keys(headers).length ? [` headers: ${JSON.stringify(headers, null, 2).replaceAll('\n', '\n ')},`] : []), + '} satisfies McpServer;', + '', + ]; + return descriptor.join('\n'); +} + +/** + * 创建可供人工恢复的旧 MCP 摘要,同时移除参数、环境值、Header 值和 URL 凭据。 + * + * @param server 无法自动迁移的旧 MCP Server。 + * @returns 不包含已知敏感值的结构化摘要。 + */ +export function redactedMcpServer(server: MCPServer): Record { + /** 清除凭据、查询和片段后的可选 URL。 */ + let url = server.url; + if (url) { + try { + /** 用于移除用户信息、查询和片段的 URL 副本。 */ + const parsed = new URL(url); + parsed.username = ''; + parsed.password = ''; + parsed.search = ''; + parsed.hash = ''; + url = parsed.href; + } catch { + url = ''; + } + } + return { + name: server.name, + ...(server.type === undefined ? {} : { type: server.type }), + ...(server.command === undefined ? {} : { command: server.command }), + ...(server.args === undefined ? {} : { args: server.args.map(() => '') }), + ...(server.env === undefined ? {} : { env: Object.fromEntries(Object.keys(server.env).sort().map(name => [name, ''])) }), + ...(url === undefined ? {} : { url }), + ...(server.headers === undefined ? {} : { headers: Object.fromEntries(Object.keys(server.headers).sort().map(name => [name, ''])) }), + }; +} diff --git a/packages/acplugin/src/migration/writers/project.ts b/packages/acplugin/src/migration/writers/project.ts new file mode 100644 index 0000000..789140a --- /dev/null +++ b/packages/acplugin/src/migration/writers/project.ts @@ -0,0 +1,224 @@ +/** 单个 Legacy ScanResult 的 canonical 工程写入编排。 */ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { stableJson, type Diagnostic } from '@acplugin/core'; +import { publicPackageRange } from '../../ecosystem/versions.js'; +import type { ScanResult } from '../legacy/types.js'; +import { + allocateMigrationIds, + compareCodeUnits, + ID_PATTERN, + relative, + safeId, +} from '../ids.js'; +import { metadataFor } from '../metadata.js'; +import type { MigrationFieldDraft, MigrationItem, MigrationOptions } from '../types.js'; +import { validateCanonicalProject } from '../validation.js'; +import { migrateAgent, migrateCommand, migrateSkill } from './components.js'; +import { copyHookReference, hookReferenceCandidates } from './hooks.js'; +import { redactedMcpServer, remoteMcpSource } from './mcp.js'; +import { + copyText, + migrationItem, + reportField, + unmapped, +} from './shared.js'; + +/** + * 把单个 Legacy ScanResult 写成完整规范工程,并用 Core Scanner 重新验证。 + * + * Instructions、原始 Hooks、不安全 MCP 和未分类文件只进入 `.acplugin-migration/unmapped`, + * 不会静默进入可发布 Plugin 内容。 + * + * @param scan 旧工程或单个旧 Plugin 的扫描结果。 + * @param outputRoot 新规范工程的阶段目录。 + * @param options 迁移元数据和严格度选项。 + * @returns 资源迁移条目与规范工程重新扫描诊断。 + */ +export async function writeCanonicalProject( + scan: ScanResult, + outputRoot: string, + options: MigrationOptions, +): Promise<{ items: MigrationItem[]; diagnostics: readonly Diagnostic[] }> { + /** 当前工程累计的资源迁移结论。 */ + const items: MigrationItem[] = []; + /** 新工程最终使用的规范元数据。 */ + const metadata = await metadataFor(scan, options, items); + // 即使旧来源只有未映射资源,也要保留合法的空 src 根以通过最终 Core 空状态校验。 + await fs.mkdir(path.join(outputRoot, 'src'), { recursive: true }); + /** 先整体分配 Skill ID,避免规范化冲突覆盖显式 ID 或依赖扫描顺序。 */ + const skills = allocateMigrationIds(scan.skills.map(skill => ({ + value: skill, + baseId: safeId(skill.dirName), + sourcePath: relative(scan.rootDir, skill.sourcePath), + }))); + /** Command 使用独立 namespace,不与 Skill/Agent 的同名资源冲突。 */ + const commands = allocateMigrationIds(scan.commands.map(command => ({ + value: command, + baseId: safeId(command.name), + sourcePath: relative(scan.rootDir, command.sourcePath), + }))); + /** Agent 使用独立 namespace,并在报告冻结前确定最终 destination。 */ + const agents = allocateMigrationIds(scan.agents.map(agent => ({ + value: agent, + baseId: safeId(agent.fileName), + sourcePath: relative(scan.rootDir, agent.sourcePath), + }))); + /** Skills、Commands 与 Agents 的并行写入任务。 */ + const writes: Promise[] = []; + for (const skill of skills) + writes.push(...migrateSkill(skill.value, skill.id, scan.rootDir, outputRoot, items)); + for (const command of commands) + writes.push(migrateCommand(command.value, command.id, scan.rootDir, outputRoot, items)); + for (const agent of agents) + writes.push(migrateAgent(agent.value, agent.id, scan.rootDir, outputRoot, items)); + await Promise.all(writes); + + for (const [index, instruction] of scan.instructions.entries()) { + /** 当前越界 Instruction 的安全未映射保留路径。 */ + const destination = await unmapped(outputRoot, 'instructions', `${index}-${instruction.fileName}`, instruction.content); + /** Instruction 原文所在的旧工程相对路径。 */ + const source = relative(scan.rootDir, instruction.sourcePath); + items.push(migrationItem({ kind: 'instruction', id: instruction.fileName, source, destination }, [{ + field: 'content', source, destination, outcome: 'unmapped', + reason: 'Instructions are outside the installable plugin boundary.', + }])); + } + + /** 是否至少自动迁移了一个安全远程 MCP,并需要启用官方 Extension。 */ + let usesMcp = false; + /** 同一配置文件中的 MCP key 使用名称补充逻辑来源,确保排序和冲突消歧稳定。 */ + const mcpSourcePath = scan.mcp === null ? undefined : relative(scan.rootDir, scan.mcp?.sourcePath ?? scan.rootDir); + /** MCP 使用自己的 namespace,显式 `foo-2` 不会被重复 `foo` 抢占。 */ + const servers = allocateMigrationIds((scan.mcp?.servers ?? []).map(server => ({ + value: server, + baseId: safeId(server.name), + sourcePath: `${mcpSourcePath ?? '.'}\0${server.name}`, + }))); + for (const allocated of servers) { + /** 当前已完成确定性 ID 分配的 Legacy MCP Server。 */ + const server = allocated.value; + /** 当前 MCP namespace 中唯一的最终 ID。 */ + const id = allocated.id; + /** 满足安全自动迁移条件时生成的类型化描述源码。 */ + const source = remoteMcpSource(server); + /** MCP 字段报告共同使用的旧配置相对路径。 */ + const sourcePath = relative(scan.rootDir, scan.mcp!.sourcePath); + if (source) { + /** 自动迁移的远程 MCP 类型化描述文件路径。 */ + const destination = `src/mcp/${id}/mcp.ts`; + await copyText(path.join(outputRoot, destination), source); + /** 安全远程 MCP 的全部声明字段。 */ + const fields: MigrationFieldDraft[] = []; + reportField(fields, 'name', sourcePath, ID_PATTERN.test(server.name) && server.name === id ? 'mapped' : 'degraded', ID_PATTERN.test(server.name) && server.name === id + ? 'Server key maps directly to the canonical MCP ID.' + : 'Server identity required lowercase kebab-case normalization or a deterministic collision suffix.'); + reportField(fields, 'transport', sourcePath, 'mapped', 'Remote HTTP transport maps to the canonical MCP descriptor.'); + reportField(fields, 'url', sourcePath, 'mapped', 'Credential-free HTTPS URL maps to the canonical MCP descriptor.'); + for (const name of Object.keys(server.headers ?? {}).sort(compareCodeUnits)) { + reportField(fields, `headers.${name}`, sourcePath, 'mapped', name.toLowerCase() === 'authorization' + ? 'Environment-only Authorization maps to canonical bearer auth without reading the secret.' + : 'Environment-only header maps without reading the secret value.'); + } + items.push(migrationItem({ kind: 'mcp', id, source: sourcePath, destination }, fields)); + usesMcp = true; + } else { + /** 无法自动迁移 MCP 的脱敏未映射记录路径。 */ + const destination = await unmapped(outputRoot, 'mcp', `${id}.json`, stableJson({ [server.name]: redactedMcpServer(server) })); + /** 无法自动迁移的 MCP 仍逐个报告实际存在字段,且不复制任何值。 */ + const fields: MigrationFieldDraft[] = []; + reportField(fields, 'name', sourcePath, ID_PATTERN.test(server.name) && server.name === id ? 'mapped' : 'degraded', ID_PATTERN.test(server.name) && server.name === id + ? 'Server key maps to the migration record identity.' + : 'Server identity required lowercase kebab-case normalization or a deterministic collision suffix.'); + for (const field of ['command', 'args', 'type', 'url'] as const) { + if (server[field] !== undefined) { + reportField(fields, field, sourcePath, 'unmapped', 'This MCP field requires a complete canonical implementation or a supported safe remote declaration.'); + } + } + for (const name of Object.keys(server.env ?? {}).sort(compareCodeUnits)) + reportField(fields, `env.${name}`, sourcePath, 'unmapped', 'Local MCP environment mapping is preserved only in the redacted sidecar.'); + for (const name of Object.keys(server.headers ?? {}).sort(compareCodeUnits)) + reportField(fields, `headers.${name}`, sourcePath, 'unmapped', 'Unsafe or literal MCP header is preserved only as a redacted field name.'); + items.push(migrationItem({ kind: 'mcp', id, source: sourcePath, destination }, fields)); + } + } + + if (scan.hooks) { + /** 原始 Hooks 配置的未映射保留路径。 */ + const destination = await unmapped(outputRoot, 'hooks', 'hooks.json', stableJson({ hooks: scan.hooks })); + /** Legacy Scanner 保留的 Hooks 配置精确来源路径。 */ + const source = scan.hooksSourcePath === undefined ? '.' : relative(scan.rootDir, scan.hooksSourcePath); + /** 每个旧事件分别进入字段报告,避免聚合配置掩盖丢失范围。 */ + const fields = Object.keys(scan.hooks).sort(compareCodeUnits).map(event => ({ + field: `event:${event}`, source, destination, outcome: 'unmapped', + reason: 'Raw legacy Hook event requires manual typed handler migration.', + })); + items.push(migrationItem({ kind: 'hooks', id: 'hooks', source, destination }, fields)); + for (const reference of hookReferenceCandidates(scan.hooks)) + await copyHookReference(scan.rootDir, reference, outputRoot, items); + } + + for (const file of scan.pluginFiles) { + /** 当前未分类 Plugin 文件的隔离保留路径。 */ + const destination = await unmapped(outputRoot, 'plugin-files', file.relativePath, file.content); + items.push(migrationItem({ kind: 'plugin-file', id: file.relativePath, source: file.relativePath, destination }, [{ + field: 'content', source: file.relativePath, destination, outcome: 'unmapped', + reason: 'Unclassified plugin files are not published automatically.', + }])); + } + + /** 规范配置入口及按需追加的 Platform/Extension 导入。 */ + const imports = [ + `import { defineConfig } from '@tokenroll/acplugin';`, + `import claudeCode from '@tokenroll/acplugin-platform-claude-code';`, + ]; + if (usesMcp) + imports.push(`import mcp from '@tokenroll/acplugin-extension-mcp';`); + /** 按稳定顺序组成且只包含已知字段的最终配置行。 */ + const configLines = [ + 'export default defineConfig({', + ` name: ${JSON.stringify(metadata.name)},`, + ` version: ${JSON.stringify(metadata.version)},`, + ` description: ${JSON.stringify(metadata.description)},`, + ]; + if (metadata.displayName !== undefined) + configLines.push(` displayName: ${JSON.stringify(metadata.displayName)},`); + if (metadata.author !== undefined) + configLines.push(` author: ${JSON.stringify(metadata.author)},`); + if (metadata.homepage !== undefined) + configLines.push(` homepage: ${JSON.stringify(metadata.homepage)},`); + if (metadata.repository !== undefined) + configLines.push(` repository: ${JSON.stringify(metadata.repository)},`); + if (metadata.license !== undefined) + configLines.push(` license: ${JSON.stringify(metadata.license)},`); + if (metadata.keywords !== undefined) + configLines.push(` keywords: ${JSON.stringify(metadata.keywords)},`); + if (usesMcp) + configLines.push(' extensions: [mcp()],'); + configLines.push(' platforms: [claudeCode()],'); + configLines.push(' build: { strict: false },', '});'); + await copyText(path.join(outputRoot, 'acplugin.config.ts'), `${imports.join('\n')}\n\n${configLines.join('\n')}\n`); + /** 新工程基础开发依赖及按需追加的官方 MCP Extension。 */ + const devDependencies: Record = { + '@tokenroll/acplugin': publicPackageRange('@tokenroll/acplugin'), + '@tokenroll/acplugin-platform-claude-code': publicPackageRange('@tokenroll/acplugin-platform-claude-code'), + 'typescript': '^7.0.2', + '@types/node': '^20.19.0', + }; + if (usesMcp) + devDependencies['@tokenroll/acplugin-extension-mcp'] = publicPackageRange('@tokenroll/acplugin-extension-mcp'); + await copyText(path.join(outputRoot, 'package.json'), stableJson({ + name: metadata.name, + version: metadata.version, + private: true, + type: 'module', + packageManager: 'pnpm@10.34.5', + scripts: { validate: 'acplugin validate', inspect: 'acplugin inspect', build: 'acplugin build', typecheck: 'tsc --noEmit' }, + devDependencies, + })); + await copyText(path.join(outputRoot, 'tsconfig.json'), stableJson({ compilerOptions: { target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext', strict: true, noEmit: true, types: ['node'], skipLibCheck: true }, include: ['acplugin.config.ts', 'src/**/*.ts'] })); + await copyText(path.join(outputRoot, '.gitignore'), 'node_modules\ndist\n.acplugin-migration/unmapped/\n'); + + // 只有正式公开 Pipeline 能证明生成配置与实际 Extension/Platform 契约共同成立。 + return { items, diagnostics: await validateCanonicalProject(outputRoot, usesMcp, metadata) }; +} diff --git a/packages/acplugin/src/migration/writers/shared.ts b/packages/acplugin/src/migration/writers/shared.ts new file mode 100644 index 0000000..e0ae594 --- /dev/null +++ b/packages/acplugin/src/migration/writers/shared.ts @@ -0,0 +1,103 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import matter from 'gray-matter'; +import { compareCodeUnits } from '../ids.js'; +import type { + MigrationFieldDraft, + MigrationFieldOutcome, + MigrationItem, + MigrationOutcome, +} from '../types.js'; + +/** 字段结论从完整保真到无法映射的严重度顺序。 */ +const FIELD_OUTCOME_RANK: Readonly> = { + mapped: 0, + degraded: 1, + unmapped: 2, +}; + +/** 记录一个已发现字段的脱敏迁移结论。 */ +export function reportField( + fields: MigrationFieldDraft[], + field: string, + source: string, + outcome: MigrationFieldOutcome, + reason: string, + destination?: string, +): void { + fields.push({ field, source, outcome, reason, ...(destination === undefined ? {} : { destination }) }); +} + +/** 按字段最差结论创建唯一的资源级迁移记录。 */ +export function migrationItem( + resource: Omit, + fields: readonly MigrationFieldDraft[], +): MigrationItem { + /** 未输出文件的聚合记录统一指向人工可审查的迁移报告。 */ + const destination = resource.destination ?? '.acplugin-migration/report.json'; + /** 字段最差结果决定资源总体,不允许 unmapped 被压低成 degraded。 */ + const worst = fields.reduce( + (current, field) => FIELD_OUTCOME_RANK[field.outcome] > FIELD_OUTCOME_RANK[current] ? field.outcome : current, + 'mapped', + ); + /** 字段 mapped 对应资源 migrated,其余名称在两个协议中一致。 */ + const outcome: MigrationOutcome = worst === 'mapped' ? 'migrated' : worst; + return { + ...resource, + outcome, + fields: Object.freeze(fields.map(field => Object.freeze({ ...field, destination: field.destination ?? destination }))), + }; +} + +/** 判断路径是否可访问。 */ +export async function exists(file: string): Promise { + try { + await fs.access(file); + return true; + } catch { + return false; + } +} + +/** 确保父目录存在后写入迁移文本文件。 */ +export async function copyText(destination: string, content: string): Promise { + await fs.mkdir(path.dirname(destination), { recursive: true }); + await fs.writeFile(destination, content); +} + +/** 为 Migration 自己生成的 Frontmatter 递归固定对象键顺序。 */ +function sortFrontmatter(value: unknown): unknown { + if (Array.isArray(value)) + return value.map(sortFrontmatter); + if (value !== null && typeof value === 'object') { + return Object.fromEntries(Object.entries(value as Record) + .filter(entry => entry[1] !== undefined) + .sort(([left], [right]) => compareCodeUnits(left, right)) + .map(([key, child]) => [key, sortFrontmatter(child)])); + } + return value; +} + +/** 组合确定性 YAML Frontmatter 与规范 Markdown 正文。 */ +export function markdownWithFrontmatter(frontmatter: Record, body: string): string { + return matter.stringify(body.trim(), sortFrontmatter(frontmatter) as Record); +} + +/** 创建父目录后按原始字节复制可信来源文件。 */ +export async function copyBytes(source: string, destination: string): Promise { + await fs.mkdir(path.dirname(destination), { recursive: true }); + await fs.copyFile(source, destination); +} + +/** 把无法安全自动迁移的文本保存在专用未映射目录。 */ +export async function unmapped( + outputRoot: string, + category: string, + filename: string, + content: string, +): Promise { + /** 与可发布源码隔离的未映射目标路径。 */ + const destination = `.acplugin-migration/unmapped/${category}/${filename}`; + await copyText(path.join(outputRoot, destination), content); + return destination; +} diff --git a/packages/acplugin/src/scaffolding/init.ts b/packages/acplugin/src/scaffolding/init.ts new file mode 100644 index 0000000..15f9bd6 --- /dev/null +++ b/packages/acplugin/src/scaffolding/init.ts @@ -0,0 +1,86 @@ +import { spawn } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { + InitError, + resolveInitAnswers, + resolveInitDestination, + type InitOptions, + type InitResult, +} from './prompts.js'; +import { createScaffoldTemplates } from './templates.js'; + +export { InitError } from './prompts.js'; +export type { InitOptions, InitPlatformId, InitResult } from './prompts.js'; + +/** 确认脚手架目标不存在或是空的普通目录。 */ +async function assertDestination(directory: string): Promise { + try { + /** 已存在目标的文件类型和符号链接状态。 */ + const stat = await fs.lstat(directory); + if (!stat.isDirectory() || stat.isSymbolicLink()) + throw new InitError('destination exists and is not a regular directory'); + if ((await fs.readdir(directory)).length > 0) + throw new InitError('destination directory is not empty'); + } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') + return; + throw error; + } +} + +/** 在新工程中运行 pnpm install,并把子进程失败转换为布尔结果。 */ +async function installDependencies(directory: string): Promise { + return new Promise((resolve) => { + /** 继承当前终端输入输出的 pnpm 子进程。 */ + const child = spawn('pnpm', ['install'], { cwd: directory, stdio: 'inherit' }); + child.once('error', () => resolve(false)); + child.once('exit', code => resolve(code === 0)); + }); +} + +/** + * 交互式或无交互地创建一个最小、可构建的规范 Plugin 工程。 + * + * @param options 目标目录、元数据、Extension 和依赖安装选项。 + * @returns 创建文件、启用 Extension 与安装状态。 + */ +export async function initializeProject(options: InitOptions): Promise { + /** 目录先独立解析和验证,保持其他交互不会在无效目标上发生。 */ + const destination = await resolveInitDestination(options); + await assertDestination(destination.directory); + /** 所有模板输入都已应用默认值并通过提示层验证。 */ + const answers = await resolveInitAnswers(options, destination.directory); + + /** 默认 Skill 的目录,也是 mkdir 一次创建整个工程树的锚点。 */ + const skillDirectory = path.join(destination.directory, 'src', 'skills', answers.name); + await fs.mkdir(skillDirectory, { recursive: true }); + if (answers.hooks) + await fs.mkdir(path.join(destination.directory, 'src', 'hooks'), { recursive: true }); + if (answers.mcp) + await fs.mkdir(path.join(destination.directory, 'src', 'mcp'), { recursive: true }); + if (answers.nodeRuntime) + await fs.mkdir(path.join(destination.directory, 'src', 'runtime'), { recursive: true }); + + /** 模板模块唯一确定文件顺序和生成字节。 */ + const templates = createScaffoldTemplates(answers); + /** 使用 `wx` 并行写入,既减少脚手架耗时,也避免覆盖并发创建的文件。 */ + await Promise.all(templates.map(template => fs.writeFile( + path.join(destination.directory, ...template.path.split('/')), + template.content, + { flag: 'wx' }, + ))); + + /** 仅在用户显式请求时执行的依赖安装结果。 */ + const installed = options.install ? await installDependencies(destination.directory) : false; + return { + directory: path.relative(destination.cwd, destination.directory) || '.', + files: templates.map(template => template.path), + platforms: answers.platforms, + extensions: [ + ...(answers.hooks ? ['@tokenroll/acplugin-extension-hooks'] : []), + ...(answers.mcp ? ['@tokenroll/acplugin-extension-mcp'] : []), + ], + installed, + }; +} diff --git a/packages/acplugin/src/scaffolding/prompts.ts b/packages/acplugin/src/scaffolding/prompts.ts new file mode 100644 index 0000000..9f07cf4 --- /dev/null +++ b/packages/acplugin/src/scaffolding/prompts.ts @@ -0,0 +1,188 @@ +import path from 'node:path'; +import { checkbox, input } from '@inquirer/prompts'; +import { isInitPlatformId } from './templates.js'; + +/** 控制 `acplugin init` 的交互方式、工程元数据和可选框架能力。 */ +export interface InitOptions { + /** 解析目标目录的工作目录,默认为当前进程目录。 */ + cwd?: string; + /** 新工程目录;显式传入 `.` 可使用当前目录。 */ + directory?: string; + /** 是否跳过交互并接受确定性默认值。 */ + yes?: boolean; + /** 可选的 Plugin 机器名称覆盖。 */ + name?: string; + /** 可选的展示名称覆盖。 */ + displayName?: string; + /** 可选的 Plugin 描述覆盖。 */ + description?: string; + /** 需要显式写入配置的官方 Platform;默认 Claude Code 与 Codex。 */ + platforms?: readonly InitPlatformId[]; + /** 是否在生成配置中启用官方 Hooks Extension。 */ + hooks?: boolean; + /** 是否在生成配置中启用官方 MCP Extension。 */ + mcp?: boolean; + /** 是否生成 Core 内建 Node Runtime 的约定入口模板。 */ + nodeRuntime?: boolean; + /** 是否在脚手架完成后运行 pnpm install。 */ + install?: boolean; +} + +/** 初始化完成后供 CLI 文本或 JSON 输出使用的稳定结果。 */ +export interface InitResult { + /** 相对于 cwd 的新工程目录。 */ + directory: string; + /** 脚手架创建的工程文件路径。 */ + files: readonly string[]; + /** 新工程启用的官方 Platform ID。 */ + platforms: readonly InitPlatformId[]; + /** 新工程启用的官方 Extension 包名。 */ + extensions: readonly string[]; + /** 请求安装依赖时,pnpm 是否成功退出。 */ + installed: boolean; +} + +/** `init` 可以写入脚手架的六个官方 Platform ID。 */ +export type InitPlatformId = 'claude-code' | 'codex' | 'cursor' | 'antigravity' | 'opencode' | 'pi'; + +/** 只承载可安全向 CLI 用户展示的已知脚手架输入错误。 */ +export class InitError extends Error { + /** 稳定标识内部错误类别,但不进入公开 facade。 */ + override readonly name = 'InitError'; +} + +/** 已解析的目标目录,在其他交互提示之前执行物理边界校验。 */ +export interface InitDestination { + readonly cwd: string; + readonly directory: string; +} + +/** 已完成默认值、交互和输入验证的脚手架选择。 */ +export interface InitAnswers { + readonly name: string; + readonly displayName: string; + readonly description: string; + readonly platforms: readonly InitPlatformId[]; + readonly hooks: boolean; + readonly mcp: boolean; + readonly nodeRuntime: boolean; +} + +/** 无交互脚手架默认启用的正式支持 Platform。 */ +const DEFAULT_PLATFORMS: readonly InitPlatformId[] = ['claude-code', 'codex']; + +/** Plugin 名称接受的小写 kebab-case 格式。 */ +const NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** 从目标目录名称派生合法且稳定的默认 Plugin 名称。 */ +function defaultName(directory: string): string { + return path.basename(directory) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') || 'my-plugin'; +} + +/** 把 kebab-case Plugin 名称转换为默认英文展示名称。 */ +function defaultDisplayName(name: string): string { + return name.split('-').map(part => part.charAt(0).toUpperCase() + part.slice(1)).join(' '); +} + +/** 只解析目录提示,使调用方能在其余交互前验证目标目录。 */ +export async function resolveInitDestination(options: InitOptions): Promise { + /** 解析相对目标目录使用的绝对工作目录。 */ + const cwd = path.resolve(options.cwd ?? process.cwd()); + /** CLI 参数或交互提示提供的原始目录值。 */ + let directoryValue = options.directory; + if (!directoryValue) { + if (options.yes || !process.stdin.isTTY) + throw new InitError('A destination directory is required in non-interactive mode; pass "." explicitly for the current directory.'); + directoryValue = await input({ message: 'Project directory', default: 'my-plugin' }); + } + return Object.freeze({ cwd, directory: path.resolve(cwd, directoryValue) }); +} + +/** 解析并验证目录之后的元数据、Platform 与可选能力提示。 */ +export async function resolveInitAnswers(options: InitOptions, directory: string): Promise { + /** 根据目录名推导的默认机器名称。 */ + const suggestedName = defaultName(directory); + /** 参数、确定性默认值或交互输入得到的最终 Plugin 名称。 */ + const name = options.name ?? (options.yes || !process.stdin.isTTY + ? suggestedName + : await input({ message: 'Plugin name', default: suggestedName })); + if (!NAME_PATTERN.test(name)) + throw new InitError('Plugin name must be lowercase kebab-case.'); + /** 根据机器名称推导的默认展示名称。 */ + const suggestedDisplayName = defaultDisplayName(name); + /** 参数、默认值或交互输入得到的最终展示名称。 */ + const displayName = options.displayName ?? (options.yes || !process.stdin.isTTY + ? suggestedDisplayName + : await input({ message: 'Display name', default: suggestedDisplayName })); + /** 参数、默认值或交互输入得到的 Plugin 描述。 */ + const description = options.description ?? (options.yes || !process.stdin.isTTY + ? `${displayName} plugin.` + : await input({ message: 'Description', default: `${displayName} plugin.` })); + if (description.trim() === '') + throw new InitError('Description must not be empty.'); + + /** 参数、默认值或交互复选提示得到的官方 Platform 列表。 */ + let platforms = options.platforms === undefined ? [...DEFAULT_PLATFORMS] : [...options.platforms]; + if (!options.yes && process.stdin.isTTY && options.platforms === undefined) { + platforms = await checkbox({ + message: 'Platforms', + choices: [ + { name: 'Claude Code', value: 'claude-code', checked: true }, + { name: 'Codex', value: 'codex', checked: true }, + { name: 'Cursor', value: 'cursor' }, + { name: 'Antigravity', value: 'antigravity' }, + { name: 'OpenCode', value: 'opencode' }, + { name: 'Pi', value: 'pi' }, + ], + required: true, + }); + } + if (platforms.length === 0) + throw new InitError('At least one Platform must be selected.'); + /** seenPlatforms 用于拒绝重复工厂,保持配置与报告身份唯一。 */ + const seenPlatforms = new Set(); + for (const platform of platforms) { + if (!isInitPlatformId(platform)) + throw new InitError(`Unknown init Platform "${platform}".`); + if (seenPlatforms.has(platform)) + throw new InitError(`Duplicate init Platform "${platform}".`); + seenPlatforms.add(platform); + } + + /** 新工程是否启用 Hooks Extension。 */ + let hooks = options.hooks ?? false; + /** 新工程是否启用 MCP Extension。 */ + let mcp = options.mcp ?? false; + /** 新工程是否生成 Core 内建 Node Runtime 模板。 */ + let nodeRuntime = options.nodeRuntime ?? false; + if (!options.yes + && process.stdin.isTTY + && options.hooks === undefined + && options.mcp === undefined + && options.nodeRuntime === undefined) { + /** 用户在统一可选能力提示中选择的功能。 */ + const selected = await checkbox({ + message: 'Optional Features', + choices: [ + { name: 'Hooks', value: 'hooks' }, + { name: 'MCP', value: 'mcp' }, + { name: 'Node Runtime', value: 'node-runtime' }, + ], + }); + hooks = selected.includes('hooks'); + mcp = selected.includes('mcp'); + nodeRuntime = selected.includes('node-runtime'); + } + return Object.freeze({ + name, + displayName, + description: description.trim(), + platforms, + hooks, + mcp, + nodeRuntime, + }); +} diff --git a/packages/acplugin/src/scaffolding/templates.ts b/packages/acplugin/src/scaffolding/templates.ts new file mode 100644 index 0000000..dca14f7 --- /dev/null +++ b/packages/acplugin/src/scaffolding/templates.ts @@ -0,0 +1,146 @@ +import { publicPackageRange } from '../ecosystem/versions.js'; +import type { InitPlatformId } from './prompts.js'; + +/** 每个独立版本化官方 Platform 的 package、配置工厂导出名与脚手架依赖范围。 */ +const PLATFORM_PACKAGES: Readonly> = { + 'claude-code': { packageName: '@tokenroll/acplugin-platform-claude-code', factory: 'claudeCode', version: publicPackageRange('@tokenroll/acplugin-platform-claude-code') }, + 'codex': { packageName: '@tokenroll/acplugin-platform-codex', factory: 'codex', version: publicPackageRange('@tokenroll/acplugin-platform-codex') }, + 'cursor': { packageName: '@tokenroll/acplugin-platform-cursor', factory: 'cursor', version: publicPackageRange('@tokenroll/acplugin-platform-cursor') }, + 'antigravity': { packageName: '@tokenroll/acplugin-platform-antigravity', factory: 'antigravity', version: publicPackageRange('@tokenroll/acplugin-platform-antigravity') }, + 'opencode': { packageName: '@tokenroll/acplugin-platform-opencode', factory: 'openCode', version: publicPackageRange('@tokenroll/acplugin-platform-opencode') }, + 'pi': { packageName: '@tokenroll/acplugin-platform-pi', factory: 'pi', version: publicPackageRange('@tokenroll/acplugin-platform-pi') }, +}; + +/** 单个确定性脚手架文件及其工程相对内容。 */ +export interface ScaffoldTemplate { + readonly path: string; + readonly content: string; +} + +/** 模板生成所需的已验证输入。 */ +export interface ScaffoldTemplateOptions { + readonly name: string; + readonly displayName: string; + readonly description: string; + readonly platforms: readonly InitPlatformId[]; + readonly hooks: boolean; + readonly mcp: boolean; + readonly nodeRuntime: boolean; +} + +/** @returns 值是否为脚手架支持的官方 Platform ID。 */ +export function isInitPlatformId(value: string): value is InitPlatformId { + return Object.hasOwn(PLATFORM_PACKAGES, value); +} + +/** 生成使用顶层元数据和可选官方 Extension 的 `acplugin.config.ts`。 */ +function configSource(metadata: ScaffoldTemplateOptions): string { + /** 配置入口以及每个选中 Platform 的独立 package 默认导入。 */ + const imports = [ + `import { defineConfig } from '@tokenroll/acplugin';`, + ...metadata.platforms.map((platform) => { + /** 当前官方 Platform 的 package 名和本地工厂名。 */ + const definition = PLATFORM_PACKAGES[platform]; + return `import ${definition.factory} from '${definition.packageName}';`; + }), + ]; + /** 写入配置 `extensions` 数组的初始化表达式。 */ + const extensions: string[] = []; + if (metadata.hooks) { + imports.push(`import hooks from '@tokenroll/acplugin-extension-hooks';`); + extensions.push('hooks()'); + } + if (metadata.mcp) { + imports.push(`import mcp from '@tokenroll/acplugin-extension-mcp';`); + extensions.push('mcp()'); + } + return `${imports.join('\n')} + +export default defineConfig({ + name: ${JSON.stringify(metadata.name)}, + version: '0.1.0', + description: ${JSON.stringify(metadata.description)}, + displayName: ${JSON.stringify(metadata.displayName)}, + platforms: [${metadata.platforms.map(platform => `${PLATFORM_PACKAGES[platform].factory}()`).join(', ')}],${extensions.length + ? ` + extensions: [${extensions.join(', ')}],` + : ''} +}); +`; +} + +/** 生成仅包含工程开发依赖和标准命令的私有 package.json。 */ +function packageSource(options: ScaffoldTemplateOptions): string { + /** 根据 Extension 选择动态扩展的开发依赖映射。 */ + const devDependencies: Record = { + '@tokenroll/acplugin': publicPackageRange('@tokenroll/acplugin'), + '@types/node': '^20.19.0', + 'typescript': '^7.0.2', + }; + for (const platform of options.platforms) { + /** 官方 Platform 独立发布后由自身元数据决定脚手架依赖范围。 */ + const definition = PLATFORM_PACKAGES[platform]; + devDependencies[definition.packageName] = definition.version; + } + if (options.hooks) + devDependencies['@tokenroll/acplugin-extension-hooks'] = publicPackageRange('@tokenroll/acplugin-extension-hooks'); + if (options.mcp) + devDependencies['@tokenroll/acplugin-extension-mcp'] = publicPackageRange('@tokenroll/acplugin-extension-mcp'); + return `${JSON.stringify({ + name: options.name, + version: '0.1.0', + private: true, + type: 'module', + packageManager: 'pnpm@10.34.5', + engines: { node: '^20.19.0 || ^22.13.0 || >=23.5.0' }, + scripts: { + dev: 'acplugin dev', + validate: 'acplugin validate', + inspect: 'acplugin inspect', + build: 'acplugin build', + typecheck: 'tsc --noEmit', + }, + devDependencies, + }, null, 2)}\n`; +} + +/** @returns 与旧 init 完全相同顺序和字节的全部脚手架模板。 */ +export function createScaffoldTemplates(options: ScaffoldTemplateOptions): readonly ScaffoldTemplate[] { + return Object.freeze([ + Object.freeze({ path: 'acplugin.config.ts', content: configSource(options) }), + Object.freeze({ path: 'package.json', content: packageSource(options) }), + Object.freeze({ + path: 'tsconfig.json', + content: `${JSON.stringify({ + compilerOptions: { + target: 'ES2022', + module: 'NodeNext', + moduleResolution: 'NodeNext', + strict: true, + noEmit: true, + types: ['node'], + skipLibCheck: true, + }, + include: ['acplugin.config.ts', 'src/**/*.ts'], + }, null, 2)}\n`, + }), + Object.freeze({ path: '.gitignore', content: 'node_modules\ndist\n' }), + Object.freeze({ + path: `src/skills/${options.name}/SKILL.md`, + content: `--- +description: Describe when and why to use ${options.displayName}. +--- +Replace this text with the focused workflow ${options.displayName} should perform. +`, + }), + ...(options.nodeRuntime + ? [Object.freeze({ + path: 'src/runtime/main.ts', + content: `import process from 'node:process'; + +process.stdout.write('ACPlugin Node runtime is ready.\\n'); +`, + })] + : []), + ]); +} diff --git a/packages/acplugin/src/sdk.ts b/packages/acplugin/src/sdk.ts new file mode 100644 index 0000000..bf82152 --- /dev/null +++ b/packages/acplugin/src/sdk.ts @@ -0,0 +1,2 @@ +// 该 subpath 是 Platform/Extension 的唯一可信集成入口;私有 Core 实现由主包构建内联。 +export * from '@acplugin/core/integration'; diff --git a/packages/acplugin/test/dev-session.test.ts b/packages/acplugin/test/dev-session.test.ts new file mode 100644 index 0000000..80405c4 --- /dev/null +++ b/packages/acplugin/test/dev-session.test.ts @@ -0,0 +1,292 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + defineExtension, + definePlatform, + type ManagedRolldownPlugin, + type SourceFileRef, +} from '@acplugin/core/integration'; +import { createProject } from '../src/author/project.js'; + +/** DevSession 程序化测试统一清理的临时工程根。 */ +const roots: string[] = []; + +/** 配置 Module 与测试进程共享 Platform 的稳定全局键。 */ +const PLATFORM_KEY = Symbol.for('tokenroll.acplugin.dev-session-test-platform'); + +/** 配置 Module 与测试进程共享 Extension 的稳定全局键。 */ +const EXTENSION_KEY = Symbol.for('tokenroll.acplugin.dev-session-test-extension'); + +/** 在动态 rebuild 中建立 close 竞态的测试控制器。 */ +interface DevControl { + round: number; + readonly started: Promise; + start(): void; + readonly gate: Promise; + release(): void; +} + +afterEach(async () => { + Reflect.deleteProperty(globalThis, PLATFORM_KEY); + Reflect.deleteProperty(globalThis, EXTENSION_KEY); + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +/** @returns 可在第二轮 Platform package 阶段暂停的程序化工程。 */ +async function fixture(options: { readonly pauseSecond?: boolean; readonly extension?: unknown } = {}): Promise<{ readonly root: string; readonly control: DevControl }> { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-dev-session-')); + roots.push(root); + await fs.mkdir(path.join(root, 'src', 'commands'), { recursive: true }); + await fs.writeFile(path.join(root, 'src', 'commands', 'review.md'), [ + '---', 'description: Review changes.', '---', 'Review the initial change.', '', + ].join('\n')); + /** 第二轮进入 Platform package 阶段时通知测试。 */ + let notifyStarted!: () => void; + /** close() 发起后才允许第二轮完成。 */ + let releaseGate!: () => void; + const control: DevControl = { + round: 0, + started: new Promise((resolve) => { notifyStarted = resolve; }), + start: notifyStarted, + gate: new Promise((resolve) => { releaseGate = resolve; }), + release: releaseGate, + }; + Reflect.set(globalThis, PLATFORM_KEY, definePlatform({ + id: 'dev-api', + apiVersion: '1', + deliveryType: 'plugin', + createSession: () => ({ + async createPackage({ project }) { + control.round += 1; + if (control.round === 2 && options.pauseSecond !== false) { + control.start(); + await control.gate; + } + return { + documents: [], + assets: [], + compatibility: project.commands.map(command => ({ + subject: `command:${command.id}`, capability: 'component', level: 'native', reason: 'Native command.', + })), + metadata: ['name', 'version', 'description'].map(field => ({ + field, disposition: 'emitted', output: `manifest/${field}`, reason: 'Emitted metadata.', + })), + }; + }, + finalizePackage: () => ({ id: 'plugin', type: 'plugin' }), + validatePackage: () => undefined, + }), + })); + if (options.extension !== undefined) + Reflect.set(globalThis, EXTENSION_KEY, options.extension); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), ` +const platform = globalThis[Symbol.for('tokenroll.acplugin.dev-session-test-platform')]; +const extension = globalThis[Symbol.for('tokenroll.acplugin.dev-session-test-extension')]; +export default { + name: 'dev-api', version: '1.0.0', description: 'Programmatic DevSession fixture.', platforms: [platform], + extensions: extension === undefined ? [] : [extension], +}; +`); + return { root, control }; +} + +/** @returns 下一次公开 build-complete,并在命中后自动取消订阅。 */ +function nextBuildComplete(session: Awaited['dev']>>) { + return new Promise>((resolve) => { + const unsubscribe = session.subscribe((event) => { + if (event.type === 'build-complete') { + unsubscribe(); + resolve(event); + } + }); + }); +} + +/** @returns start 中包含指定逻辑 identity 的同 sequence 完成事件。 */ +function nextBuildForChange( + session: Awaited['dev']>>, + identity: string, +) { + return new Promise>((resolve) => { + /** 只有明确匹配的 start sequence 才能完成当前等待。 */ + const matching = new Set(); + const unsubscribe = session.subscribe((event) => { + if (event.type === 'build-start' && event.changes.includes(identity)) + matching.add(event.sequence); + if (event.type === 'build-complete' && matching.has(event.sequence)) { + unsubscribe(); + resolve(event); + } + }); + }); +} + +describe('DevSession API', () => { + it('pairs an active rebuild with build-complete and one closed event while removing a failed listener', async () => { + const current = await fixture(); + const session = await createProject({ cwd: current.root }).dev(); + /** initial ready 只通过 resolve/current 表达,不发布不可订阅事件。 */ + const initial = session.current; + /** 抛错 listener 只能被调用一次,随后由 Core 自动移除。 */ + let failedListenerCalls = 0; + session.subscribe(() => { + failedListenerCalls += 1; + throw new Error('listener failure'); + }); + /** 正常 listener 记录完整公开事件序列。 */ + const events: import('@acplugin/core/author').DevSessionEvent[] = []; + session.subscribe(event => events.push(event)); + + await fs.writeFile(path.join(current.root, 'src', 'commands', 'review.md'), [ + '---', 'description: Review changes again.', '---', 'Review the rebuilt change.', '', + ].join('\n')); + await current.control.started; + /** 两次 close 必须共享同一个关闭任务且不抑制在途轮事件。 */ + const firstClose = session.close(); + const secondClose = session.close(); + expect(secondClose).toBe(firstClose); + current.control.release(); + await firstClose; + await session.closed; + + expect(events.map(event => event.type)).toEqual(['build-start', 'build-complete', 'closed']); + expect(events.map(event => event.sequence)).toEqual([1, 1, 1]); + expect(failedListenerCalls).toBe(1); + expect(session.current).not.toBe(initial); + expect(session.current).toMatchObject({ command: 'dev', success: true, committed: true }); + }, 10_000); + + it('keeps a failed-round external graph and reports its stable package identity on recovery', async () => { + /** 外部 package root 模拟 pnpm store/workspace package 的真实物理位置。 */ + const packageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-dev-external-package-')); + roots.push(packageRoot); + await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ + name: 'recovery-package', version: '1.0.0', type: 'module', exports: './index.js', license: 'MIT', + })); + await fs.writeFile(path.join(packageRoot, 'LICENSE'), 'Recovery package license.\n'); + const packageEntry = path.join(packageRoot, 'index.js'); + await fs.writeFile(packageEntry, 'export const value = "first";\n'); + /** 初始轮跳过 Compiler,失败轮才首次发现外部依赖。 */ + const recovery = { enabled: false, fail: false }; + const extension = defineExtension, { readonly entry: SourceFileRef }, { readonly entry: SourceFileRef }, Record>({ + id: 'recovery', + apiVersion: '1', + resourceRoots: ['recovery'], + createSession: () => ({ + async discover(context) { + return { entry: await context.sources.file(context.roots.recovery!, 'entry.ts') }; + }, + validate: (_context, discovered) => ({ state: discovered, subjects: [] }), + async build(context, validated) { + if (!recovery.enabled) + return { state: {} }; + await context.compiler.compile({ + id: 'recovery-package', + profile: 'portable-node', + entries: { main: { type: 'source', source: validated.entry } }, + }); + if (recovery.fail) + throw new Error('intentional failed round'); + return { state: {} }; + }, + contributors: [{ + platform: 'dev-api', + platformApiVersion: '1', + contribute: () => ({ compatibility: [] }), + }], + }), + }); + const current = await fixture({ pauseSecond: false, extension }); + await fs.mkdir(path.join(current.root, 'src', 'recovery'), { recursive: true }); + await fs.writeFile(path.join(current.root, 'src', 'recovery', 'entry.ts'), 'export { value } from "recovery-package";\n'); + await fs.mkdir(path.join(current.root, 'node_modules'), { recursive: true }); + await fs.symlink(packageRoot, path.join(current.root, 'node_modules', 'recovery-package'), 'dir'); + const session = await createProject({ cwd: current.root }).dev(); + /** 下一轮启用 Compiler 并在依赖图已登记后制造业务失败。 */ + recovery.enabled = true; + recovery.fail = true; + const failed = nextBuildComplete(session); + await fs.writeFile(path.join(current.root, 'src', 'commands', 'review.md'), [ + '---', 'description: Trigger failed discovery.', '---', 'Trigger the failed graph.', '', + ].join('\n')); + expect((await failed).report.success).toBe(false); + /** 只有失败轮 watch graph 被保留时,修改工程外物理文件才会触发恢复。 */ + recovery.fail = false; + const packageIdentity = 'package:recovery-package@1.0.0/index.js'; + const recovered = nextBuildForChange(session, packageIdentity); + await fs.writeFile(packageEntry, 'export const value = "second";\n'); + const recoveredEvent = await recovered; + expect(recoveredEvent.report.success).toBe(true); + expect(recoveredEvent.changes).toContain(packageIdentity); + expect(recoveredEvent.changes.some(change => change.includes('..') || change.includes(packageRoot))).toBe(false); + await session.close(); + }, 15_000); + + it('rebuilds once when an authorized pending managed watch file is created', async () => { + /** pending path 在 initial compile 时不存在,但位于 Extension 授权 source root。 */ + const pendingRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-dev-pending-')); + roots.push(pendingRoot); + const pending = path.join(pendingRoot, 'src/pending/future.config.ts'); + const plugin: ManagedRolldownPlugin = { + name: 'dev-pending-watch', + buildStart() { this.addWatchFile(pending); }, + }; + const extension = defineExtension, { readonly entry: SourceFileRef }, { readonly entry: SourceFileRef }, Record>({ + id: 'pending-watch', + apiVersion: '1', + resourceRoots: ['pending'], + createSession: () => ({ + discover: async context => ({ entry: await context.sources.file(context.roots.pending!, 'entry.ts') }), + validate: (_context, state) => ({ state, subjects: [] }), + async build(context, state) { + await context.compiler.compile({ + id: 'pending-watch', + profile: 'managed-rolldown', + entries: { main: { type: 'source', source: state.entry } }, + options: { + inputOptions: { plugins: [plugin] }, + outputs: [{ id: 'esm', options: { format: 'es' } }], + policy: { licenses: 'ignore' }, + }, + }); + return { state: {} }; + }, + contributors: [{ + platform: 'dev-api', platformApiVersion: '1', contribute: () => ({ compatibility: [] }), + }], + }), + }); + /** 实际 Project 必须使用与 pending closure 相同的物理根。 */ + await fs.mkdir(path.join(pendingRoot, 'src/commands'), { recursive: true }); + await fs.mkdir(path.join(pendingRoot, 'src/pending'), { recursive: true }); + await fs.writeFile(path.join(pendingRoot, 'src/commands/review.md'), '---\ndescription: Review.\n---\nReview.\n'); + await fs.writeFile(path.join(pendingRoot, 'src/pending/entry.ts'), 'export const value = true;\n'); + await fs.writeFile(path.join(pendingRoot, 'acplugin.config.ts'), ` +const platform = globalThis[Symbol.for('tokenroll.acplugin.dev-session-test-platform')]; +const extension = globalThis[Symbol.for('tokenroll.acplugin.dev-session-test-extension')]; +export default { + name: 'dev-api', version: '1.0.0', description: 'Pending watch fixture.', + platforms: [platform], extensions: [extension], +}; +`); + /** Platform 不暂停第二轮,Extension 通过全局 identity 进入每轮 fresh config。 */ + await fixture({ pauseSecond: false, extension }); + /** fixture() 创建的其他工程仅用于取得同一测试 Platform;实际 Session 使用 pendingRoot。 */ + const session = await createProject({ cwd: pendingRoot }).dev(); + const identity = 'src/pending/future.config.ts'; + const events: import('@acplugin/core/author').DevSessionEvent[] = []; + session.subscribe(event => events.push(event)); + const rebuilt = nextBuildForChange(session, identity); + await fs.writeFile(pending, 'export default true;\n'); + const result = await rebuilt; + + expect(result.report.success).toBe(true); + expect(result.changes).toContain(identity); + /** 等待 debounce 窗口,证明 duplicate physical subscriptions 没有产生补偿轮。 */ + await new Promise(resolve => setTimeout(resolve, 250)); + expect(events.filter(event => event.type === 'build-start' && event.changes.includes(identity))).toHaveLength(1); + await session.close(); + }, 10_000); +}); diff --git a/packages/acplugin/test/project.test.ts b/packages/acplugin/test/project.test.ts new file mode 100644 index 0000000..82b25d7 --- /dev/null +++ b/packages/acplugin/test/project.test.ts @@ -0,0 +1,115 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { definePlatform } from '@acplugin/core/integration'; +import { createProject, ProjectConfigError, runProject } from '../src/author/project.js'; + +/** 临时工程由 afterEach 统一删除。 */ +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +/** 配置 Module Host externalize 的真实测试 Platform 模块。 */ +async function fixture() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-project-api-')); + roots.push(root); + await fs.mkdir(path.join(root, 'src', 'commands'), { recursive: true }); + await fs.writeFile(path.join(root, 'src', 'commands', 'review.md'), [ + '---', 'description: Review changes.', '---', 'Review changes.', '', + ].join('\n')); + /** 共享品牌化 Platform 通过测试进程全局传入配置 Module graph。 */ + const key = Symbol.for('tokenroll.acplugin.project-api-test-platform'); + Reflect.set(globalThis, key, definePlatform({ + id: 'project-api', + apiVersion: '1', + deliveryType: 'plugin', + createSession: () => ({ + createPackage: ({ project }) => ({ + documents: [], assets: [], + compatibility: project.commands.map(command => ({ + subject: `command:${command.id}`, capability: 'component', level: 'native', reason: 'Native command.', + })), + metadata: ['name', 'version', 'description'].map(field => ({ + field, disposition: 'emitted', output: `manifest/${field}`, reason: 'Emitted metadata.', + })), + }), + finalizePackage: () => ({ id: 'plugin', type: 'plugin' }), + validatePackage: () => undefined, + }), + })); + /** Platform identity 由 config 的本地 TypeScript closure 读取。 */ + await fs.writeFile(path.join(root, 'platform.ts'), ` +export const platform = globalThis[Symbol.for('tokenroll.acplugin.project-api-test-platform')]; +`); + await fs.writeFile(path.join(root, 'value.ts'), `export const version = '1.0.0';\n`); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), ` +import { platform } from './platform.ts'; +import { version } from './value.ts'; +export default ({ command }) => ({ + name: 'project-api', version, description: command, platforms: [platform], public: false, +}); +`); + return root; +} + +describe('Project API', () => { + it('uses the same one-shot implementation for Project.run and runProject and fresh-loads config', async () => { + const root = await fixture(); + const project = createProject({ cwd: root }); + const first = await project.run({ command: 'validate' }); + const convenience = await runProject({ cwd: root, command: 'validate' }); + expect(first).toEqual(convenience); + expect(first.success).toBe(true); + expect(first.committed).toBe(false); + expect(first.command).toBe('validate'); + + await fs.writeFile(path.join(root, 'value.ts'), `export const version = '2.0.0';\n`); + const changed = await project.run({ command: 'validate' }); + expect(changed.framework).toEqual(first.framework); + expect(changed.success).toBe(true); + }); + + it('keeps config location/evaluation/schema failures outside BuildReport', async () => { + const root = await fixture(); + await fs.rm(path.join(root, 'acplugin.config.ts')); + await expect(runProject({ cwd: root, command: 'validate' })).rejects.toSatisfy((error: unknown) => + error instanceof ProjectConfigError && error.diagnostics.some(item => item.code === 'CONFIG_LOAD_FAILED')); + await expect(runProject({ cwd: root, configFile: '../escape.ts', command: 'validate' })).rejects.toBeInstanceOf(ProjectConfigError); + + await fs.writeFile(path.join(root, 'acplugin.config.ts'), 'throw new Error("secret=/private/path");\n'); + await expect(runProject({ cwd: root, command: 'validate' })).rejects.toSatisfy((error: unknown) => + error instanceof ProjectConfigError + && error.diagnostics.some(item => item.code === 'CONFIG_EVALUATION_FAILED') + && !error.diagnostics.some(item => item.message.includes('/private/path'))); + }); + + it('rejects duplicate/unknown Platform subsets before Integration setup', async () => { + const root = await fixture(); + const duplicate = await runProject({ cwd: root, command: 'validate', platforms: ['project-api', 'project-api'] }); + const unknown = await runProject({ cwd: root, command: 'validate', platforms: ['missing'] }); + expect(duplicate.success).toBe(false); + expect(unknown.success).toBe(false); + expect(duplicate.diagnostics).toContainEqual(expect.objectContaining({ code: 'PLATFORM_SELECTION_INVALID' })); + expect(unknown.diagnostics).toContainEqual(expect.objectContaining({ code: 'PLATFORM_SELECTION_INVALID' })); + }); + + it('forces validate and inspect to be read-only while honoring build commit=false', async () => { + const root = await fixture(); + const output = path.join(root, 'dist'); + + const validated = await runProject({ cwd: root, command: 'validate', commit: true }); + const inspected = await runProject({ cwd: root, command: 'inspect', commit: true }); + const dryBuild = await runProject({ cwd: root, command: 'build', commit: false }); + expect(validated).toMatchObject({ command: 'validate', success: true, committed: false }); + expect(inspected).toMatchObject({ command: 'inspect', success: true, committed: false }); + expect(dryBuild).toMatchObject({ command: 'build', success: true, committed: false }); + await expect(fs.access(output)).rejects.toThrow(); + + const committed = await runProject({ cwd: root, command: 'build' }); + expect(committed).toMatchObject({ command: 'build', success: true, committed: true }); + await fs.access(path.join(output, 'project-api', 'plugin')); + }); +}); diff --git a/packages/acplugin/test/sdk-boundary.test.ts b/packages/acplugin/test/sdk-boundary.test.ts new file mode 100644 index 0000000..c504c21 --- /dev/null +++ b/packages/acplugin/test/sdk-boundary.test.ts @@ -0,0 +1,29 @@ +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +/** 当前主包源码根。 */ +const sourceRoot = fileURLToPath(new URL('../src/', import.meta.url)); + +describe('author and SDK package boundary', () => { + it('keeps integration factories out of the root author facade', async () => { + /** root 源码用于精确断言公开边界。 */ + const root = await readFile(new URL('index.ts', new URL('../src/', import.meta.url)), 'utf8'); + + expect(root).not.toMatch(/\bdefinePlatform\b/); + expect(root).not.toMatch(/\bdefineExtension\b/); + expect(root).not.toMatch(/\bPlatformSession\b/); + expect(root).not.toMatch(/\bExtensionSession\b/); + expect(root).not.toMatch(/\bDeliveryUnit\b/); + expect(root).not.toMatch(/\bArtifactInput\b/); + }); + + it('uses the single private Core SDK entry from the public sdk subpath', async () => { + /** sdk 源码必须保持单一 re-export,以便 root/SDK/CLI 共享品牌实现。 */ + const sdk = await readFile(`${sourceRoot}sdk.ts`, 'utf8'); + + expect(sdk).toContain('export * from \'@acplugin/core/integration\''); + expect(sdk).not.toContain('@tokenroll/acplugin-platform-'); + expect(sdk).not.toContain('@tokenroll/acplugin-extension-'); + }); +}); diff --git a/packages/acplugin/tsconfig.json b/packages/acplugin/tsconfig.json new file mode 100644 index 0000000..e523885 --- /dev/null +++ b/packages/acplugin/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "exactOptionalPropertyTypes": false, + "noUncheckedIndexedAccess": false + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/acplugin/tsconfig.typedoc.json b/packages/acplugin/tsconfig.typedoc.json new file mode 100644 index 0000000..0014567 --- /dev/null +++ b/packages/acplugin/tsconfig.typedoc.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*.ts", "typedoc-entry.ts"], + "exclude": ["test/**/*.ts"] +} diff --git a/packages/acplugin/tsdown.config.ts b/packages/acplugin/tsdown.config.ts new file mode 100644 index 0000000..de2d67d --- /dev/null +++ b/packages/acplugin/tsdown.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'tsdown'; +import { fileURLToPath } from 'node:url'; + +// 主包同时生成库入口与可执行 CLI;私有 Core 会内联,Migration 通过动态导入保留独立 Chunk。 +export default defineConfig({ + entry: { + index: fileURLToPath(new URL('./src/index.ts', import.meta.url)), + sdk: fileURLToPath(new URL('./src/sdk.ts', import.meta.url)), + cli: fileURLToPath(new URL('./src/cli.ts', import.meta.url)), + }, + format: ['esm'], + platform: 'node', + target: 'node20', + dts: { generator: 'oxc' }, + clean: true, + sourcemap: false, + publint: true, + attw: { profile: 'esm-only', level: 'error' }, + deps: { + // 主包内联私有 Core 与其闭包;新增 node_modules 依赖必须显式审阅后才能进入 tarball。 + onlyBundle: ['chokidar', 'readdirp', 'smol-toml', 'yaml'], + }, +}); diff --git a/packages/acplugin/typedoc-entry.ts b/packages/acplugin/typedoc-entry.ts new file mode 100644 index 0000000..2a0d923 --- /dev/null +++ b/packages/acplugin/typedoc-entry.ts @@ -0,0 +1,42 @@ +/** + * TypeDoc-only composite of the public author and Integration SDK entry points. + * This file is excluded from package builds and tarballs. + */ +export * from './src/sdk.js'; +export { + ACPLUGIN_VERSION, + createProject, + defineConfig, + initializeProject, + nodeRuntimeArtifactPath, + nodeRuntimeLicensesArtifactPath, + ProjectConfigError, + runProject, + serializeBuildReport, +} from './src/index.js'; +export type { + BuildConfig, + BuildReport, + ComponentReport, + CreateProjectOptions, + DevSession, + DevSessionEvent, + ExtensionReport, + InitOptions, + InitPlatformId, + InitResult, + NodeRuntimeConfig, + NodeRuntimeEntryInput, + PackageAssetReport, + PackageUnitReport, + PlatformReport, + Project, + ProjectDevOptions, + ProjectRunOptions, + PublicConfig, + PublicCopyRule, + RunProjectOptions, + RuntimeReport, + UserConfig, + UserConfigExport, +} from './src/index.js'; diff --git a/packages/acplugin/typedoc.json b/packages/acplugin/typedoc.json new file mode 100644 index 0000000..dd560d3 --- /dev/null +++ b/packages/acplugin/typedoc.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "tsconfig": "tsconfig.typedoc.json", + "entryPoints": ["typedoc-entry.ts"] +} diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..d59849f --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,44 @@ +{ + "name": "@acplugin/core", + "version": "0.0.1-beta", + "private": true, + "type": "module", + "engines": { + "node": ">=20" + }, + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + }, + "./integration": { + "types": "./dist/integration.d.mts", + "import": "./dist/integration.mjs" + }, + "./author": { + "types": "./dist/author.d.mts", + "import": "./dist/author.mjs" + } + }, + "scripts": { + "build": "tsdown", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { + "chokidar": "^5.0.0", + "rolldown": "catalog:", + "semver": "^7.8.5", + "smol-toml": "^1.8.0", + "spdx-expression-parse": "^5.0.0", + "yaml": "^2.9.0" + }, + "devDependencies": { + "@types/node": "catalog:", + "@types/semver": "^7.7.1", + "@types/spdx-expression-parse": "^4.0.0", + "tsdown": "catalog:", + "@typescript/native": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/core/src/api/author.ts b/packages/core/src/api/author.ts new file mode 100644 index 0000000..8aa0b3b --- /dev/null +++ b/packages/core/src/api/author.ts @@ -0,0 +1,53 @@ +export type { + AgentCapability, + AgentModel, + AssetMode, + AssetOrigin, + BuildConfig, + BuildMode, + BuildReport, + CompatibilityEntry, + CompatibilityLevel, + ComponentReport, + ConfigCommand, + ConfigEnvironment, + CreateProjectOptions, + DevSession, + DevSessionEvent, + Diagnostic, + DiagnosticInput, + DiagnosticPhase, + ExtensionReport, + ExtensionSubject, + MetadataDisposition, + MetadataDispositionEntry, + NodeRuntimeConfig, + NodeRuntimeEntryInput, + NodeRuntimeEntryKind, + PackageAssetReport, + PackageUnitReport, + PlatformDeliveryType, + PlatformReport, + PluginAuthor, + PluginMetadata, + PortableNodeCompileOptions, + PortableNodeResolveOptions, + PortableNodeTransformOptions, + Project, + ProjectDevOptions, + ProjectRunOptions, + PublicConfig, + PublicCopyRule, + RunProjectOptions, + RuntimeReport, + SourceLocation, + UserConfig, + UserConfigExport, +} from '../contracts/index.js'; + +// 作者报告 serializer 与 SDK 工具共享同一个确定性 JSON 实现。 +export { stableJson } from '../serialization/index.js'; +export { + nodeRuntimeArtifactPath, + nodeRuntimeLicensesArtifactPath, +} from '../resources/runtime/paths.js'; diff --git a/packages/core/src/api/definitions.ts b/packages/core/src/api/definitions.ts new file mode 100644 index 0000000..1ba4256 --- /dev/null +++ b/packages/core/src/api/definitions.ts @@ -0,0 +1,325 @@ +import type { + AcpluginExtension, + AcpluginPlatform, + ExtensionDefinition, + PlatformCapabilities, + PlatformDefinition, +} from '../contracts/integrations.js'; +import type { JsonObject } from '../contracts/common.js'; +import { LIFECYCLE_API_VERSION } from '../contracts/common.js'; +import { snapshotJson } from '../security/json-snapshot.js'; +import { compareCodeUnits } from '../serialization/json.js'; + +/** Platform ID、Extension ID、Resource root 和 job ID 共用的稳定标识规则。 */ +const STABLE_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** 跨 root/SDK/CLI bundle chunk 共享的 Platform 定义品牌。 */ +const platformBrand = Symbol.for(`tokenroll.acplugin.platform.${LIFECYCLE_API_VERSION}`); + +/** 跨 root/SDK/CLI bundle chunk 共享的 Extension 定义品牌。 */ +const extensionBrand = Symbol.for(`tokenroll.acplugin.extension.${LIFECYCLE_API_VERSION}`); + +/** Platform definition 唯一允许的公共字段。 */ +const platformFields = new Set(['id', 'apiVersion', 'deliveryType', 'strict', 'options', 'capabilities', 'createSession']); + +/** Extension definition 唯一允许的公共字段。 */ +const extensionFields = new Set(['id', 'apiVersion', 'options', 'resourceRoots', 'createSession']); + +/** + * 确认对象不携带 accessor、Symbol 或不可见字段语义。 + * + * @param value 待检查对象。 + * @param label 诊断中的对象角色。 + * @returns 自有字符串字段描述符。 + */ +function dataDescriptors(value: object, label: string): Record { + /** Symbol 字段既不属于 JSON,也不能成为隐藏定义字段。 */ + const symbols = Object.getOwnPropertySymbols(value); + if (symbols.length > 0) + throw new TypeError(`${label} must not contain symbol properties.`); + /** 所有自有字符串字段的完整描述符。 */ + const descriptors = Object.getOwnPropertyDescriptors(value); + for (const [field, descriptor] of Object.entries(descriptors)) { + if (!('value' in descriptor)) + throw new TypeError(`${label}.${field} must be a data property, not an accessor.`); + } + return descriptors; +} + +/** + * 验证对象使用 plain-object 原型。 + * + * @param value 待检查值。 + * @param label 诊断中的对象角色。 + */ +function assertPlainObject(value: unknown, label: string): asserts value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) + throw new TypeError(`${label} must be a plain object.`); + /** class instance 与自定义 prototype 不属于无行为 contract。 */ + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) + throw new TypeError(`${label} must be a plain object.`); +} + +/** + * 拒绝定义上的未知字段并返回其数据描述符。 + * + * @param value 待检查定义对象。 + * @param allowed 允许字段集合。 + * @param label 诊断中的定义角色。 + * @returns 通过检查的数据描述符。 + */ +function definitionDescriptors(value: unknown, allowed: ReadonlySet, label: string): Record { + assertPlainObject(value, label); + /** 定义字段必须全部是显式 data property。 */ + const descriptors = dataDescriptors(value, label); + for (const field of Object.keys(descriptors)) { + if (!allowed.has(field)) + throw new TypeError(`Unknown ${label} field "${field}".`); + } + return descriptors; +} + +/** + * 复制一个严格 JSON 对象。 + * + * @param value 可省略的原始 options。 + * @param label 诊断中的对象角色。 + * @returns 冻结的 JSON 对象副本。 + */ +function copyJsonObject(value: unknown, label: string): Readonly { + assertPlainObject(value, label); + return snapshotJson(value, label) as Readonly; +} + +/** + * 验证 JSON 容器及全部后代都已冻结。 + * + * @param value 已通过 JSON 形态校验的候选值。 + * @returns 所有容器都冻结时返回 true。 + */ +function isDeeplyFrozenJson(value: unknown): boolean { + if (value === null || typeof value !== 'object') + return true; + if (!Object.isFrozen(value)) + return false; + if (Array.isArray(value)) + return value.every(isDeeplyFrozenJson); + return Object.values(value as Record).every(isDeeplyFrozenJson); +} + +/** + * 复制并验证开放的 Platform capability 数据。 + * + * @param value 原始 capability 对象。 + * @returns 冻结且完成 nodeRuntime 语义校验的能力副本。 + */ +function copyCapabilities(value: unknown): Readonly { + /** 未声明能力与空 capability 对象具有相同语义。 */ + const copied = copyJsonObject(value ?? {}, 'Platform capabilities') as PlatformCapabilities; + if (copied.nodeRuntime !== undefined) { + /** nodeRuntime 是 Framework 理解的唯一结构化内建能力。 */ + const runtime = copied.nodeRuntime; + assertPlainObject(runtime, 'Platform capabilities.nodeRuntime'); + /** 内建能力必须只包含固定的三个协商字段。 */ + const fields = Object.keys(dataDescriptors(runtime, 'Platform capabilities.nodeRuntime')).sort(compareCodeUnits); + if (fields.length !== 3 || fields[0] !== 'format' || fields[1] !== 'root' || fields[2] !== 'target' + || runtime.target !== 'node20' || runtime.format !== 'esm' || runtime.root !== 'plugin') { + throw new TypeError('Platform capabilities.nodeRuntime must declare Node 20 ESM at the Plugin root.'); + } + } + return copied; +} + +/** + * 验证稳定 lowercase-kebab 标识。 + * + * @param value 未知标识值。 + * @param label 诊断中的标识角色。 + * @returns 已验证标识文本。 + */ +function stableId(value: unknown, label: string): string { + if (typeof value !== 'string' || !STABLE_ID_PATTERN.test(value)) + throw new TypeError(`${label} must use lowercase kebab-case.`); + return value; +} + +/** + * 读取 definition 的已验证 data property。 + * + * @param descriptors definition 字段描述符。 + * @param field 要读取的字段。 + * @returns 未知字段值。 + */ +function definitionValue(descriptors: Record, field: string): unknown { + return descriptors[field]?.value; +} + +/** + * 使用共享品牌构造最终 Platform definition。 + * + * @param definition trusted integration 提交的平台定义。 + * @returns 完成形态校验、复制和冻结的平台定义。 + */ +export function definePlatform< + const O extends JsonObject, + TComponent extends JsonObject = never, +>(definition: PlatformDefinition): AcpluginPlatform { + /** definition 顶层必须是精确 plain-object contract。 */ + const descriptors = definitionDescriptors(definition, platformFields, 'Platform definition'); + /** API version 在任何回调运行前检查。 */ + if (definitionValue(descriptors, 'apiVersion') !== LIFECYCLE_API_VERSION) + throw new TypeError(`Unsupported Platform API version; expected ${LIFECYCLE_API_VERSION}.`); + /** delivery type 决定主 Package 的语义而不是输出路径。 */ + const deliveryType = definitionValue(descriptors, 'deliveryType'); + if (deliveryType !== 'plugin' && deliveryType !== 'workspace' && deliveryType !== 'package') + throw new TypeError('Platform deliveryType must be plugin, workspace, or package.'); + /** 可选 strict override 必须是布尔值。 */ + const strict = definitionValue(descriptors, 'strict'); + if (strict !== undefined && typeof strict !== 'boolean') + throw new TypeError('Platform strict must be a boolean when provided.'); + /** Session factory 是 definition 唯一持有行为的入口。 */ + const createSession = definitionValue(descriptors, 'createSession'); + if (typeof createSession !== 'function') + throw new TypeError('Platform createSession must be a function.'); + /** options 总是复制,使调用方后续 mutation 不影响配置。 */ + const options = copyJsonObject(definitionValue(descriptors, 'options') ?? {}, 'Platform options'); + /** capabilities 使用同一 JSON 复制规则并额外验证内建能力。 */ + const capabilities = copyCapabilities(definitionValue(descriptors, 'capabilities')); + /** 最终外壳不展开原始对象,避免未知继承字段进入实例。 */ + const platform = { + id: stableId(definitionValue(descriptors, 'id'), 'Platform id'), + apiVersion: LIFECYCLE_API_VERSION, + deliveryType, + ...(strict === undefined ? {} : { strict }), + options, + capabilities, + createSession, + }; + Object.defineProperty(platform, platformBrand, { value: true, enumerable: false }); + return Object.freeze(platform) as unknown as AcpluginPlatform; +} + +/** + * 验证未知值是否为当前主包工厂创建的完整 Platform definition。 + * + * @param value 配置加载器收到的候选值。 + * @returns 品牌和完整外壳均有效时返回 true。 + */ +export function isAcpluginPlatform(value: unknown): value is AcpluginPlatform { + if (typeof value !== 'object' || value === null) + return false; + /** 共享 Symbol 只解决 bundle identity,完整 shape 仍独立验证。 */ + const candidate = value as Record; + if (candidate[platformBrand] !== true || !Object.isFrozen(value)) + return false; + try { + /** 工厂实例包含共享 registry brand,因此允许这一个已知 Symbol 后检查公共字段。 */ + const symbols = Object.getOwnPropertySymbols(value); + if (symbols.length !== 1 || symbols[0] !== platformBrand) + return false; + /** 品牌不可枚举、不可写且不可配置。 */ + const brand = Object.getOwnPropertyDescriptor(value, platformBrand); + if (brand?.value !== true || brand.enumerable || brand.writable || brand.configurable) + return false; + /** 公共 shape 和深冻 JSON 数据仍必须完整。 */ + const fields = Object.keys(value).sort(compareCodeUnits); + /** factory 必须始终物化这些规范字段,strict 是唯一可选字段。 */ + const required = ['apiVersion', 'capabilities', 'createSession', 'deliveryType', 'id', 'options']; + if (fields.some(field => !platformFields.has(field)) || required.some(field => !fields.includes(field))) + return false; + if (candidate.apiVersion !== LIFECYCLE_API_VERSION || typeof candidate.id !== 'string' || !STABLE_ID_PATTERN.test(candidate.id) + || (candidate.deliveryType !== 'plugin' && candidate.deliveryType !== 'workspace' && candidate.deliveryType !== 'package') + || (candidate.strict !== undefined && typeof candidate.strict !== 'boolean') || typeof candidate.createSession !== 'function') + return false; + copyJsonObject(candidate.options, 'Platform options'); + copyCapabilities(candidate.capabilities); + return isDeeplyFrozenJson(candidate.options) && isDeeplyFrozenJson(candidate.capabilities); + } catch { + return false; + } +} + +/** + * 使用共享品牌构造最终 Extension definition。 + * + * @param definition trusted integration 提交的 Extension 定义。 + * @returns 完成形态校验、复制和冻结的 Extension 定义。 + */ +export function defineExtension< + const O extends JsonObject, + D = unknown, + V = D, + B = V, +>(definition: ExtensionDefinition): AcpluginExtension { + /** definition 顶层必须是精确 plain-object contract。 */ + const descriptors = definitionDescriptors(definition, extensionFields, 'Extension definition'); + if (definitionValue(descriptors, 'apiVersion') !== LIFECYCLE_API_VERSION) + throw new TypeError(`Unsupported Extension API version; expected ${LIFECYCLE_API_VERSION}.`); + /** Session factory 是 Extension definition 唯一持有行为的入口。 */ + const createSession = definitionValue(descriptors, 'createSession'); + if (typeof createSession !== 'function') + throw new TypeError('Extension createSession must be a function.'); + /** resource root 必须是互不重复的 srcDir 一级稳定目录名。 */ + const rawRoots = definitionValue(descriptors, 'resourceRoots'); + if (!Array.isArray(rawRoots)) + throw new TypeError('Extension resourceRoots must be an array.'); + /** 已复制的 root 数组隔离调用方 mutation。 */ + const resourceRoots = rawRoots.map(root => stableId(root, 'Extension resource root')); + if (new Set(resourceRoots).size !== resourceRoots.length) + throw new TypeError('Extension resourceRoots must not contain duplicates.'); + /** options 与 Platform 使用完全相同的 JSON contract。 */ + const options = copyJsonObject(definitionValue(descriptors, 'options') ?? {}, 'Extension options'); + /** 最终外壳仅包含 v2 definition 字段。 */ + const extension = { + id: stableId(definitionValue(descriptors, 'id'), 'Extension id'), + apiVersion: LIFECYCLE_API_VERSION, + options, + resourceRoots: Object.freeze(resourceRoots), + createSession, + }; + Object.defineProperty(extension, extensionBrand, { value: true, enumerable: false }); + return Object.freeze(extension) as unknown as AcpluginExtension; +} + +/** + * 验证未知值是否为当前主包工厂创建的完整 Extension definition。 + * + * @param value 配置加载器收到的候选值。 + * @returns 品牌和完整外壳均有效时返回 true。 + */ +export function isAcpluginExtension(value: unknown): value is AcpluginExtension { + if (typeof value !== 'object' || value === null) + return false; + /** 共享 Symbol 只解决 bundle identity,完整 shape 仍独立验证。 */ + const candidate = value as Record; + if (candidate[extensionBrand] !== true || !Object.isFrozen(value)) + return false; + try { + /** 工厂实例只允许自己的不可变共享 registry brand。 */ + const symbols = Object.getOwnPropertySymbols(value); + if (symbols.length !== 1 || symbols[0] !== extensionBrand) + return false; + /** 品牌必须由 defineProperty 的默认只读策略创建。 */ + const brand = Object.getOwnPropertyDescriptor(value, extensionBrand); + if (brand?.value !== true || brand.enumerable || brand.writable || brand.configurable) + return false; + /** 公共字段不能在工厂之后被伪造或遗漏。 */ + const fields = Object.keys(value).sort(compareCodeUnits); + /** Extension factory 始终物化完整的五字段外壳。 */ + const required = ['apiVersion', 'createSession', 'id', 'options', 'resourceRoots']; + if (fields.some(field => !extensionFields.has(field)) || required.some(field => !fields.includes(field))) + return false; + if (candidate.apiVersion !== LIFECYCLE_API_VERSION || typeof candidate.id !== 'string' || !STABLE_ID_PATTERN.test(candidate.id) + || typeof candidate.createSession !== 'function' || !Array.isArray(candidate.resourceRoots) + || !Object.isFrozen(candidate.resourceRoots) || !isDeeplyFrozenJson(candidate.options)) + return false; + /** 再次运行纯验证,确保跨 bundle 输入仍满足完整 shape。 */ + copyJsonObject(candidate.options, 'Extension options'); + /** 所有 root 再次通过稳定 ID 校验并检查唯一性。 */ + const roots = candidate.resourceRoots.map(root => stableId(root, 'Extension resource root')); + return new Set(roots).size === roots.length; + } catch { + return false; + } +} diff --git a/packages/core/src/api/integration.ts b/packages/core/src/api/integration.ts new file mode 100644 index 0000000..4b2e3df --- /dev/null +++ b/packages/core/src/api/integration.ts @@ -0,0 +1,134 @@ +export { + defineExtension, + definePlatform, + isAcpluginExtension, + isAcpluginPlatform, +} from './definitions.js'; +export { + LIFECYCLE_API_VERSION, +} from '../contracts/index.js'; +export type { + AcpluginExtension, + AcpluginPlatform, + AgentCapability, + AgentComponent, + AgentModel, + AssetContributor, + AssetMode, + AssetOrigin, + AssetRef, + AssetService, + Awaitable, + BuildMode, + BytesAssetRef, + CanonicalProject, + CommandComponent, + CompatibilityEntry, + CompatibilityInput, + CompatibilityLevel, + CompileEntry, + CompileJob, + CompileModuleReport, + CompileOptions, + CompileOptionsMap, + CompileOutputFile, + CompileProfile, + CompileResult, + CompilerService, + ComponentLocation, + ComponentRequires, + ConfigCommand, + ConfigEnvironment, + ContributedPackageComponent, + ContributionContext, + CreatePackageContext, + Diagnostic, + DiagnosticInput, + DiagnosticPhase, + DiagnosticService, + DistributionAssetInput, + DistributionContext, + DistributionPackageInput, + DocumentFieldContribution, + DocumentFieldPath, + ExecutionResult, + ExecutionService, + ExtensionBuildContext, + ExtensionBuildOutput, + ExtensionDefinition, + ExtensionDiscoverContext, + ExtensionIntegrationDescription, + ExtensionSession, + ExtensionSetupContext, + ExtensionSubject, + ExtensionValidateContext, + ExtensionValidationOutput, + FinalizePackageContext, + FinalizationAssetService, + FinalizationGeneratedBytesOriginInput, + ForbiddenManagedPluginHook, + GeneratedAssetRef, + GeneratedBytesOriginInput, + IntegrationCloseContext, + IntegrationDescription, + JsonObject, + JsonPrimitive, + JsonValue, + ManagedRolldownCompileOptions, + ManagedRolldownInputOptions, + ManagedRolldownOutputOptions, + ManagedRolldownPlugin, + ManagedRolldownPluginOption, + MergedPackageSnapshot, + MetadataDisposition, + MetadataDispositionEntry, + MetadataDispositionInput, + ModuleService, + NodeRuntimeCapability, + NodeRuntimeEntryKind, + NodeRuntimeResource, + PackageAssetInput, + PackageAssetSnapshot, + PackageCandidate, + PackageComponentInput, + PackageComponentOrigin, + PackageContribution, + PackageDocumentInput, + PackageDocumentSnapshot, + PlatformFinalizationFieldContribution, + PackageUnitSnapshot, + PlatformBasePackageSnapshot, + PlatformCapabilities, + PlatformComponentValidationContext, + PlatformContributor, + PlatformDefinition, + PlatformDeliveryType, + PlatformIntegrationDescription, + PlatformPackageInput, + PlatformSession, + PlatformSetupContext, + PluginAuthor, + PluginMetadata, + PortableNodeCompileOptions, + PortableNodeResolveOptions, + PortableNodeTransformOptions, + PrimaryPackageInput, + PublicResourceFile, + ResolvedConfigSummary, + SkillComponent, + SourceAssetRef, + SourceDirectoryRef, + SourceEntry, + SourceFileRef, + SourceLocation, + SourceService, + ValidatePackageContext, +} from '../contracts/index.js'; + +// SDK 只导出 Integration 编写所需的纯序列化工具,不公开 Registry 或 Kernel 实现。 +export { + markdownWithFrontmatter, + stableJson, + stableYaml, +} from '../serialization/index.js'; +export { snapshotJson } from '../security/json-snapshot.js'; diff --git a/packages/core/src/compiler/compiler-service.ts b/packages/core/src/compiler/compiler-service.ts new file mode 100644 index 0000000..2eecd01 --- /dev/null +++ b/packages/core/src/compiler/compiler-service.ts @@ -0,0 +1,772 @@ +/** Core 唯一 CompilerService 编排 owner-scoped Rolldown Job。 */ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { + CompileJob, + CompileOutputFile, + CompileProfile, + CompileResult, + CompilerService, + ManagedRolldownCompileOptions, + ManagedRolldownPlugin, +} from '../contracts/compiler.js'; +import { AssetRegistry } from '../services/assets.js'; +import { compareCodePoints, isInsidePath, safeRelativePath } from '../security/path-policy.js'; +import { SourceRegistry } from '../services/sources.js'; +import { WatchRegistry, type WatchObservation } from '../services/watch.js'; +import { WorkDirectoryRegistry } from '../services/work-directories.js'; +import { + auditManagedModules, + auditManagedOutput, + managedModuleWatchObservations, + managedModuleReports, + type AuditedModule, + type EngineModuleSnapshot, + type ManagedAuditScopes, +} from './managed/auditor.js'; +import { loadManagedEngine, type EngineInputOptions, type EngineOutputOptions, type ManagedEngine } from './engine-loader.js'; +import { + assertStableId, + dataProperties, + prepareCompileSources, + resolveCompileSources, + type NormalizedEntry, + type VirtualSource, +} from './job-normalizer.js'; +import { managedSourceBoundaryPlugin, type ManagedPackageScope } from './managed/boundary.js'; +import { normalizeManagedInput, normalizeManagedOutput } from './managed/options.js'; +import type { NormalizedManagedInput, NormalizedManagedOutput } from './managed/options.js'; +import { auditPortableOutput, mergePortableModuleReports } from './portable-node/auditor.js'; +import { collectCompilerLicenses, type CompilerLicenseResult } from './license-pipeline.js'; +import { normalizePortableOptions, type NormalizedPortableOptions } from './portable-node/options.js'; +import { + assertPortableEntryExtension, + normalizeNodeBuiltin, + portableNodePolicyPlugin, +} from './portable-node/policy.js'; + +/** 单个 Compiler Host 使用的 Session registries。 */ +export interface CompilerHostOptions { + readonly projectRoot: string; + readonly sources: SourceRegistry; + readonly workDirectories: WorkDirectoryRegistry; + readonly assets: AssetRegistry; + readonly watch: WatchRegistry; +} + +/** 快照并授权后的 managed job。 */ +interface NormalizedManagedJob { + readonly id: string; + readonly entries: readonly NormalizedEntry[]; + readonly virtualSources: ReadonlyMap; + readonly sourceRoots: readonly string[]; + readonly input: NormalizedManagedInput; + readonly outputs: readonly { readonly id: string; readonly output: NormalizedManagedOutput }[]; + readonly policy?: ManagedRolldownCompileOptions['policy']; +} + +/** 快照并授权后的 portable-node job。 */ +interface NormalizedPortableJob { + readonly id: string; + readonly entries: readonly NormalizedEntry[]; + readonly virtualSources: ReadonlyMap; + readonly sourceRoots: readonly string[]; + readonly options: NormalizedPortableOptions; +} + +/** + * 建立 Core 自有的虚拟 entry/module Plugin。 + * + * @param sources 原生虚拟 ID 到代码的快照。 + * @returns 只处理当前 Job 精确虚拟 ID 的 Plugin。 + */ +function virtualSourcePlugin(sources: ReadonlyMap): ManagedRolldownPlugin { + return Object.freeze({ + name: 'acplugin-virtual-sources', + /** 解析当前 Job 的虚拟 ID 和虚拟 entry 相对导入。 */ + resolveId(source, importer) { + /** 公开 specifier 与内部 NUL ID 都只能命中已快照集合。 */ + if (sources.has(source)) + return source; + /** 公开 specifier 对应的 Core 内部 NUL ID。 */ + const internal = `\0acplugin:module:${source}`; + if (sources.has(internal)) + return internal; + /** importer 对应的虚拟源快照。 */ + const record = importer === undefined ? undefined : sources.get(importer); + if (record?.resolveFrom !== undefined && (source.startsWith('./') || source.startsWith('../'))) + return this.resolve(source, path.join(record.resolveFrom, '__acplugin_entry__.mjs'), { skipSelf: true }); + return null; + }, + /** 为字符串动态 import 提供与静态 import 一致的解析。 */ + resolveDynamicImport(source, importer) { + /** 字符串动态 import 与静态 import 使用相同虚拟解析规则。 */ + if (typeof source !== 'string') + return null; + /** 动态 importer 对应的虚拟源快照。 */ + const record = importer === undefined ? undefined : sources.get(importer); + return record?.resolveFrom !== undefined && (source.startsWith('./') || source.startsWith('../')) + ? path.resolve(record.resolveFrom, source) + : null; + }, + /** 返回当前 Job 已快照的虚拟源码。 */ + load(id) { + return sources.get(id)?.code ?? null; + }, + }); +} + +/** + * 建立最终模块图采样 Plugin。 + * + * 该 Plugin 可被 trusted Plugin 干扰,因此它只提供数据;Host 在 + * generate() 返回后独立验证图完整性与所有安全不变量。 + * + * @param graph 当前 output 的原始模块图容器。 + * @returns 最后一个 generateBundle 采样器。 + */ +function moduleGraphPlugin(graph: Map): ManagedRolldownPlugin { + return Object.freeze({ + name: 'acplugin-module-graph-audit', + generateBundle: { + order: 'post' as const, + /** 在当次 output 所有 Plugin 完成后采样最终模块图。 */ + handler(_options, outputBundle) { + graph.clear(); + /** 只读取 ModuleInfo 的四个审计数组,避免触发 ast 等不支持 getter。 */ + const capture = (id: string): void => { + /** 当前 ID 对应的 Rolldown 模块信息。 */ + const info = this.getModuleInfo(id); + if (info !== null) { + graph.set(id, Object.freeze({ + importedIds: Object.freeze([...info.importedIds]), + dynamicallyImportedIds: Object.freeze([...info.dynamicallyImportedIds]), + importers: Object.freeze([...info.importers]), + dynamicImporters: Object.freeze([...info.dynamicImporters]), + })); + } + }; + for (const id of this.getModuleIds()) { + capture(id); + } + /** Rolldown output 有时保留与 getModuleIds() 不同的规范 ID,两者必须同时采样。 */ + for (const item of Object.values(outputBundle)) { + if (item.type === 'chunk') { + for (const id of [...item.moduleIds, ...Object.keys(item.modules)]) + capture(id); + } + } + }, + }, + }); +} + +/** + * 把 Rolldown watch file 限制在已审计模块或 owner workDir。 + * + * @param file Rolldown/Plugin 登记的物理路径。 + * @param modules 已通过最终边界审计的模块。 + * @param scopes 当前 owner 授权根。 + * @returns 可交给唯一 DevSession watcher 的规范路径。 + */ +async function auditedWatchFile( + file: string, + modules: readonly AuditedModule[], + scopes: ManagedAuditScopes, +): Promise { + if (typeof file !== 'string' || file.includes('\0') || !path.isAbsolute(file)) + throw new Error('Managed Rolldown watch files must be absolute physical paths.'); + /** existing 使用 realpath;missing 通过最深已存在祖先进入相同真实路径基准。 */ + const existing = await fs.realpath(file).catch(() => undefined); + /** 尚未创建部分从目标向已存在祖先反向积累。 */ + const suffix: string[] = []; + /** missing watch 候选的当前祖先。 */ + let ancestor = path.normalize(file); + while (existing === undefined && !await fs.lstat(ancestor).then(() => true).catch(() => false)) { + /** 当前祖先的父目录用于检测文件系统根并继续向上。 */ + const parent = path.dirname(ancestor); + if (parent === ancestor) + throw new Error('Managed Rolldown watch file has no existing ancestor.'); + suffix.push(path.basename(ancestor)); + ancestor = parent; + } + /** 已存在祖先本身不能是 symlink/special file。 */ + const ancestorStat = existing === undefined ? await fs.lstat(ancestor) : undefined; + if (ancestorStat !== undefined && (ancestorStat.isSymbolicLink() || !ancestorStat.isDirectory())) + throw new Error('Managed Rolldown watch file must have a regular directory ancestor.'); + /** canonical missing path 保留尚不存在的最终 segment。 */ + const normalized = existing ?? path.join(await fs.realpath(ancestor), ...suffix.reverse()); + /** missing watch file 只能位于 source root;workDir 不作为作者恢复入口。 */ + const pending = existing === undefined; + /** 最终模块图中的全部物理文件。 */ + const moduleFiles = new Set(modules + .map(module => module.physicalId.replace(/\?.*$/u, '')) + .filter(id => path.isAbsolute(id)) + .map(id => path.normalize(id))); + /** 当前观察是否位于 owner 已授权的源码根。 */ + const inSource = scopes.sourceRoots.some(root => isInsidePath(root, normalized)); + /** pending 只能位于 source;existing 还可属于最终 module graph 或 owner workDir。 */ + const inWork = isInsidePath(scopes.workRoot, normalized); + if (!moduleFiles.has(normalized) && !inSource && (pending || !inWork)) { + throw new Error('Managed Rolldown registered a watch file outside its authorized module graph.'); + } + return Object.freeze({ path: normalized, type: 'file' as const, ...(pending ? { pending: true } : {}) }); +} + +/** Core 唯一的、可为多 owner 签发 service 的 Compiler Host。 */ +export class CompilerHost { + /** 工程绝对根,用于 Rolldown cwd 和报告路径。 */ + readonly #projectRoot: string; + /** SourceRef 对象 identity 授权注册表。 */ + readonly #sources: SourceRegistry; + /** owner workDir 对象 identity 授权注册表。 */ + readonly #workDirectories: WorkDirectoryRegistry; + /** GeneratedAssetRef 唯一签发注册表。 */ + readonly #assets: AssetRegistry; + /** BuildSession 唯一 Watch Registry。 */ + readonly #watch: WatchRegistry; + /** 进程内精确 Rolldown 驱动的延迟加载结果。 */ + readonly #engine: Promise; + /** owner 内已消费 job ID,防止 workDir 结果被重用。 */ + readonly #jobs = new Map>(); + + /** + * 创建一个 BuildSession 唯一 Compiler Host。 + * + * @param options 当前 Session 的 capability registries 与 Watch 出口。 + */ + constructor(options: CompilerHostOptions) { + this.#projectRoot = path.resolve(options.projectRoot); + this.#sources = options.sources; + this.#workDirectories = options.workDirectories; + this.#assets = options.assets; + this.#watch = options.watch; + this.#engine = loadManagedEngine(); + } + + /** + * 为一个 Integration/Framework owner 创建闭包绑定的 CompilerService。 + * + * @param owner Kernel 固定的 owner ID。 + * @returns 不接受调用方自报 owner 的 SDK service。 + */ + async service(owner: string): Promise { + if (typeof owner !== 'string' || owner.length === 0) + throw new TypeError('Compiler owner must be a non-empty string.'); + /** 当前安装的精确 Rolldown 驱动。 */ + const engine = await this.#engine; + /** engine 信息和 compile 闭包不暴露 Host 或 registries。 */ + return Object.freeze({ + engine: Object.freeze({ name: engine.name, version: engine.version }), + /** 编译请求自动绑定当前 owner。 */ + compile:

(job: CompileJob

) => this.#compile(owner, engine, job), + }); + } + + /** + * 快照并授权 managed Job 全部来源。 + * + * @param owner 当前 service owner。 + * @param job 调用方 Job。 + * @returns 已与调用方容器解除引用的内部请求。 + */ + async #normalizeManaged(owner: string, job: CompileJob<'managed-rolldown'>): Promise { + /** Job 公共来源在任何异步 Plugin/I/O 之前完成容器快照。 */ + const pending = prepareCompileSources(owner, 'managed-rolldown', job, this.#sources); + /** managed Profile 的原始 options 容器。 */ + const options = pending.options; + /** managed options 的全部 data property。 */ + const optionDescriptors = dataProperties(options, 'Managed compile options'); + for (const field of Object.keys(optionDescriptors)) { + if (!new Set(['inputOptions', 'outputs', 'policy']).has(field)) + throw new TypeError(`Managed compile options.${field} is unknown.`); + } + if (!Array.isArray(optionDescriptors.outputs?.value) || optionDescriptors.outputs.value.length === 0) + throw new TypeError('Managed compile options.outputs must be a non-empty array.'); + /** output 数组在任何 Plugin Promise await 前复制当前元素。 */ + const rawOutputs = [...optionDescriptors.outputs.value] as unknown[]; + /** 当前 Job 已声明的唯一 output ID。 */ + const outputIds = new Set(); + /** input 参数在任何 Plugin Promise 解析前同步建立容器快照。 */ + const inputPromise = normalizeManagedInput(optionDescriptors.inputOptions?.value); + /** 后续同步字段验证失败时也必须立即观察 Plugin Promise rejection。 */ + void inputPromise.catch(() => undefined); + /** 所有 output 先同步读取 descriptor,不让前一个 Promise 打开 mutation 窗口。 */ + const outputPromises = rawOutputs.map((rawOutput, index) => { + /** 当前 output 的全部 data property。 */ + const output = dataProperties(rawOutput, `Managed output[${index}]`); + for (const field of Object.keys(output)) { + if (field !== 'id' && field !== 'options') + throw new TypeError(`Managed output[${index}].${field} is unknown.`); + } + assertStableId(output.id?.value, 'Managed output id'); + /** 经过 ID assertion 后固定当前输出身份。 */ + const outputId = output.id.value; + if (outputIds.has(outputId)) + throw new TypeError(`Managed output id "${outputId}" is duplicated.`); + outputIds.add(outputId); + /** 当前 output 的异步 Plugin 解析与结构快照。 */ + const outputPromise = normalizeManagedOutput(output.options?.value, `Managed output "${outputId}" options`).then(normalized => Object.freeze({ + id: outputId, + output: normalized, + })); + /** 任何后续 output/policy 同步失败都不能留下未观察 rejection。 */ + void outputPromise.catch(() => undefined); + return outputPromise; + }); + /** 调用方提供的原始审计策略。 */ + const rawPolicy = optionDescriptors.policy?.value; + /** policy 是小型数据对象,逐字段复制并校验。 */ + let policy: ManagedRolldownCompileOptions['policy']; + if (rawPolicy !== undefined) { + /** 审计策略的全部 data property。 */ + const values = dataProperties(rawPolicy, 'Managed compile policy'); + /** 当前 managed Profile 已定义的策略字段。 */ + const allowed = new Set(['deterministic', 'licenses', 'nativeAddons', 'unresolvedImports']); + for (const field of Object.keys(values)) { + if (!allowed.has(field)) + throw new TypeError(`Managed compile policy.${field} is unknown.`); + } + if (values.deterministic !== undefined && typeof values.deterministic.value !== 'boolean') + throw new TypeError('Managed compile policy.deterministic must be boolean.'); + if (values.licenses !== undefined && values.licenses.value !== 'strict' && values.licenses.value !== 'ignore') + throw new TypeError('Managed compile policy.licenses must be strict or ignore.'); + if (values.nativeAddons !== undefined && values.nativeAddons.value !== 'reject' && values.nativeAddons.value !== 'allow') + throw new TypeError('Managed compile policy.nativeAddons must be reject or allow.'); + if (values.unresolvedImports !== undefined && values.unresolvedImports.value !== 'reject' && values.unresolvedImports.value !== 'allow') + throw new TypeError('Managed compile policy.unresolvedImports must be reject or allow.'); + policy = Object.freeze({ + ...(values.deterministic === undefined ? {} : { deterministic: values.deterministic.value as boolean }), + ...(values.licenses === undefined ? {} : { licenses: values.licenses.value as 'strict' | 'ignore' }), + ...(values.nativeAddons === undefined ? {} : { nativeAddons: values.nativeAddons.value as 'reject' | 'allow' }), + ...(values.unresolvedImports === undefined ? {} : { unresolvedImports: values.unresolvedImports.value as 'reject' | 'allow' }), + }); + } + /** 到此才执行来源树 I/O;调用方容器已完全断开。 */ + const normalizedSources = await resolveCompileSources(owner, pending, this.#sources); + /** Plugin Promise 已解析且结构快照完成的 input options。 */ + const normalizedInput = await inputPromise; + /** managed tsconfig 只接受当前 owner/Session 的精确 SourceFileRef。 */ + let tsconfig: string | false = false; + if (normalizedInput.tsconfig !== undefined && normalizedInput.tsconfig !== false) { + /** SourceRegistry 授权并复核后的 tsconfig 文件记录。 */ + const record = await this.#sources.validatedFile(owner, normalizedInput.tsconfig); + tsconfig = await fs.realpath(record.physicalPath); + } + return Object.freeze({ + id: normalizedSources.id, + entries: normalizedSources.entries, + virtualSources: normalizedSources.virtualSources, + sourceRoots: normalizedSources.sourceRoots, + input: Object.freeze({ + ...normalizedInput, + options: Object.freeze({ ...normalizedInput.options, tsconfig }), + }), + outputs: Object.freeze(await Promise.all(outputPromises)), + ...(policy === undefined ? {} : { policy }), + }); + } + + /** + * 快照并授权 portable-node Job 全部来源和 JSON options。 + * + * @param owner 当前 service owner。 + * @param job 调用方 portable Job。 + * @returns 固定 Node contract 可直接执行的内部请求。 + */ + async #normalizePortable(owner: string, job: CompileJob<'portable-node'>): Promise { + /** 来源和 options 都在第一个 I/O 前完成同步容器快照。 */ + const pending = prepareCompileSources(owner, 'portable-node', job, this.#sources); + /** portable JSON subset 的运行时快照。 */ + const options = normalizePortableOptions(pending.options); + /** 已通过作者树和 SourceRef 复核的物理来源。 */ + const sources = await resolveCompileSources(owner, pending, this.#sources); + for (const entry of sources.entries) + assertPortableEntryExtension(entry.inputId); + return Object.freeze({ ...sources, options }); + } + + /** + * 消费 owner 内唯一 Job ID。 + * + * @param owner 当前 service owner。 + * @param id 已规范化 stable Job ID。 + */ + #consumeJob(owner: string, id: string): void { + /** owner 间相同 ID 不冲突,同 owner 当次 Session 不能覆盖既有 work 输出。 */ + const jobs = this.#jobs.get(owner) ?? new Set(); + if (jobs.has(id)) + throw new Error(`Compile job id "${id}" was already used by this owner.`); + jobs.add(id); + this.#jobs.set(owner, jobs); + } + + /** + * 执行 owner-scoped Compiler Job。 + * + * @param owner 当前 service owner。 + * @param engine 已加载精确 Rolldown 驱动。 + * @param job SDK Job。 + * @returns 仅包含 GeneratedAssetRef 与脱敏模块图的结果。 + */ + async #compile

( + owner: string, + engine: ManagedEngine, + job: CompileJob

, + ): Promise> { + if (job.profile === 'portable-node') + return this.#compilePortable(owner, engine, job as CompileJob<'portable-node'>) as Promise>; + if (job.profile !== 'managed-rolldown') + throw new Error('Compile profile is not supported by this Core version.'); + /** 与调用方容器隔离且完成来源授权的 Job。 */ + const normalized = await this.#normalizeManaged(owner, job as CompileJob<'managed-rolldown'>); + /** job ID 在开始任何引擎工作前一次性消费。 */ + this.#consumeJob(owner, normalized.id); + /** 当前 owner 的唯一 workDir 句柄。 */ + const workDirectory = await this.#workDirectories.directory(owner); + /** 仅 Core 可见的 owner 物理工作根。 */ + const workRoot = this.#workDirectories.physicalRoot(owner, workDirectory); + /** 只有 source boundary resolver 证明的 package 才可通过最终审计。 */ + const packages = new Map(); + /** 最终 resolver/module/output/watch 共用的审计边界。 */ + const scopes = Object.freeze({ + projectRoot: await fs.realpath(this.#projectRoot), + sourceRoots: normalized.sourceRoots, + workRoot, + packages, + }); + /** Core 从 entry ID 独立建立 Rolldown 命名 input。 */ + const input = Object.freeze(Object.fromEntries(normalized.entries.map(entry => [entry.id, entry.inputId]))); + /** 已展平并快照的 trusted input Plugin。 */ + const userPlugins = normalized.input.plugins; + /** Core 重建入口、cwd、日志和 watch 边界的最终 input options。 */ + const inputOptions = Object.freeze({ + ...normalized.input.options, + input, + cwd: this.#projectRoot, + logLevel: 'silent' as const, + watch: false, + plugins: [ + managedSourceBoundaryPlugin({ sourceRoots: normalized.sourceRoots, workRoot, packages }), + ...userPlugins, + virtualSourcePlugin(normalized.virtualSources), + ], + }); + /** create 成功后无论 generate/audit/sign 如何失败都必须 close。 */ + let bundle: Awaited> | undefined; + try { + bundle = await engine.create(inputOptions); + /** 多 output 按声明顺序依次调用同一 build object generate()。 */ + const outputs: CompileOutputFile[] = []; + /** 第一个 output 确定且后续 output 必须一致的模块报告。 */ + let moduleReports: ReturnType | undefined; + /** 同一 managed Job 全部 output 的最终 watch observations。 */ + const watchObservations = new Map(); + /** 所有签发 Asset 共享最终完整模块来源。 */ + let originInputs: readonly string[] = []; + for (const output of normalized.outputs) { + /** deterministic 模式必须避免 Rolldown 非 whitespace 输出注入物理 module region。 */ + if (normalized.policy?.deterministic === true && output.output.options.minify === false) + throw new TypeError('Managed deterministic output cannot disable whitespace normalization.'); + /** 未指定 minify 时只规范 whitespace,不压缩表达式或改写名称。 */ + const effectiveOutput = normalized.policy?.deterministic === true && output.output.options.minify === undefined + ? Object.freeze({ ...output.output.options, minify: Object.freeze({ compress: false, mangle: false }) }) + : output.output.options; + /** 每个 output 独立采样当次 generate 的最终图。 */ + const graph = new Map(); + /** Rolldown generate() 返回的原始内存输出。 */ + const raw = await bundle.generate(Object.freeze({ + ...effectiveOutput, + plugins: [ + ...output.output.plugins, + moduleGraphPlugin(graph), + ], + })); + /** 在 Plugin 顺序之外通过的最终模块图。 */ + const modules = await auditManagedModules(graph, scopes); + /** 通过路径、闭包和策略审计的内存输出。 */ + const files = auditManagedOutput(raw, modules, normalized.policy, [ + this.#projectRoot, + scopes.projectRoot, + scopes.workRoot, + ...scopes.sourceRoots, + ...[...scopes.packages.values()].map(dependency => dependency.root), + ]); + /** managed 默认 strict;显式 ignore 才由可信集成自行承担法律材料。 */ + const licenses: CompilerLicenseResult = normalized.policy?.licenses === 'ignore' + ? Object.freeze({ inputs: Object.freeze([] as string[]), watchFiles: Object.freeze([] as string[]) }) + : await collectCompilerLicenses(modules, packages); + if (licenses.bytes !== undefined && files.some(file => file.fileName === 'THIRD_PARTY_LICENSES.txt')) + throw new Error('Managed Rolldown output conflicts with Core license material.'); + if (moduleReports === undefined) { + moduleReports = managedModuleReports(modules); + originInputs = Object.freeze(moduleReports.map(module => module.id).sort(compareCodePoints)); + } else if (JSON.stringify(moduleReports) !== JSON.stringify(managedModuleReports(modules))) { + throw new Error('Managed Rolldown multi-output builds must expose one stable module graph.'); + } + for (const file of files) { + /** output ID 与 fileName 分层写入 owner workDir,不会碰触 dist。 */ + const relative = safeRelativePath(`compile/${normalized.id}/${output.id}/${file.fileName}`); + /** owner workDir 内的唯一物理输出路径。 */ + const physical = this.#workDirectories.resolve(owner, workDirectory, relative); + await fs.mkdir(path.dirname(physical), { recursive: true, mode: 0o700 }); + await fs.writeFile(physical, file.bytes, { flag: 'wx', mode: 0o600 }); + /** 用于继承 mode 的入口声明。 */ + const entry = file.entryId === undefined + ? undefined + : normalized.entries.find(candidate => candidate.id === file.entryId); + /** 经 Asset Registry 签发的不可伪造输出 ref。 */ + const asset = await this.#assets.issueGenerated( + owner, + workDirectory, + relative, + entry?.mode ?? 0o644, + { job: normalized.id, output: output.id, profile: 'managed-rolldown', kind: file.type, inputs: originInputs }, + ); + outputs.push(Object.freeze({ + type: file.type, + outputId: output.id, + fileName: file.fileName, + ...(file.entryId === undefined ? {} : { entryId: file.entryId }), + isEntry: file.isEntry, + asset, + })); + } + if (licenses.bytes !== undefined) { + /** 当前 managed output 相邻的固定法律材料路径。 */ + const relative = safeRelativePath(`compile/${normalized.id}/${output.id}/THIRD_PARTY_LICENSES.txt`); + /** 法律材料只写当前 owner workDir。 */ + const physical = this.#workDirectories.resolve(owner, workDirectory, relative); + await fs.writeFile(physical, licenses.bytes, { flag: 'wx', mode: 0o600 }); + /** managed 法律材料也保留完整 compile provenance。 */ + const asset = await this.#assets.issueGenerated( + owner, + workDirectory, + relative, + 0o644, + { job: normalized.id, output: output.id, profile: 'managed-rolldown', kind: 'licenses', inputs: [...originInputs, ...licenses.inputs] }, + ); + outputs.push(Object.freeze({ + type: 'licenses' as const, + outputId: output.id, + fileName: 'THIRD_PARTY_LICENSES.txt', + isEntry: false, + asset, + })); + } + /** 最终模块图中的 package 文件保留逻辑 watch identity。 */ + for (const observation of managedModuleWatchObservations(modules)) + watchObservations.set(observation.path, observation); + /** Plugin/Rolldown watchFiles 必须位于已授权图边界。 */ + for (const file of (await bundle.watchFiles).sort(compareCodePoints)) { + /** 当前 Plugin watch file 的授权后真实路径。 */ + const audited = await auditedWatchFile(file, modules, scopes); + watchObservations.set(audited.path, watchObservations.get(audited.path) ?? audited); + } + /** package manifest/legal 使用安全 package identity。 */ + for (const file of licenses.watchFiles) { + /** 当前法律文件所属的 resolver-proven package。 */ + const dependency = [...packages.values()].find(candidate => isInsidePath(candidate.root, file)); + watchObservations.set(file, Object.freeze({ + path: file, + type: 'file' as const, + ...(dependency === undefined ? {} : { identity: `package:${dependency.name}@${dependency.version}/${path.basename(file)}` }), + })); + } + } + await this.#watch.replace(owner, `compiler/${normalized.id}`, [...watchObservations.values()]); + return Object.freeze({ + job: normalized.id, + profile: 'managed-rolldown', + engine: Object.freeze({ name: engine.name, version: engine.version }), + outputs: Object.freeze(outputs), + modules: moduleReports ?? Object.freeze([]), + }) as CompileResult

; + } finally { + await bundle?.close(); + } + } + + /** + * 使用固定 Node 20 ESM policy 为每个入口生成独立 self-contained Bundle。 + * + * @param owner 当前 service owner。 + * @param engine Core 唯一 Rolldown driver。 + * @param job portable-node Job。 + * @returns main/license GeneratedAssetRef 与合并后的安全模块图。 + */ + async #compilePortable( + owner: string, + engine: ManagedEngine, + job: CompileJob<'portable-node'>, + ): Promise> { + /** 完成全部 Ref/options 授权后才消费 Job ID。 */ + const normalized = await this.#normalizePortable(owner, job); + this.#consumeJob(owner, normalized.id); + /** 当前 owner 的唯一 workDir 与内部物理根。 */ + const workDirectory = await this.#workDirectories.directory(owner); + /** 只有 Host 可见的 owner workDir 根。 */ + const workRoot = this.#workDirectories.physicalRoot(owner, workDirectory); + /** 用于来源报告和路径泄露审计的工程真实根。 */ + const projectRoot = await fs.realpath(this.#projectRoot); + /** 多入口结果按稳定 entry ID 顺序生成和返回。 */ + const outputs: CompileOutputFile[] = []; + /** 各独立入口的脱敏模块报告。 */ + const reports: ReturnType[] = []; + /** 整个 portable Job 的统一 watch observation snapshot。 */ + const watchObservations = new Map(); + for (const entry of normalized.entries) { + /** 每个入口独立证明实际打包 package graph,不共享 Chunk 或 license 集合。 */ + const packages = new Map(); + /** 当前 entry 的完整最终审计边界。 */ + const scopes = Object.freeze({ projectRoot, sourceRoots: normalized.sourceRoots, workRoot, packages }); + /** Core policy Plugin 不由调用方提供或排序。 */ + const graph = new Map(); + /** readonly SDK options 在 Core 边界转换为 Rolldown 当前需要的 mutable array copies。 */ + const resolve = normalized.options.resolve === undefined + ? undefined + : { + ...(normalized.options.resolve.conditionNames === undefined ? {} : { conditionNames: [...normalized.options.resolve.conditionNames] }), + ...(normalized.options.resolve.extensions === undefined ? {} : { extensions: [...normalized.options.resolve.extensions] }), + ...(normalized.options.resolve.mainFields === undefined ? {} : { mainFields: [...normalized.options.resolve.mainFields] }), + ...(normalized.options.resolve.mainFiles === undefined ? {} : { mainFiles: [...normalized.options.resolve.mainFiles] }), + }; + /** transform 同样只复制 portable subset,不允许 Core-owned 字段混入。 */ + const transform = normalized.options.transform === undefined + ? { target: 'node20' } + : { + ...(normalized.options.transform.define === undefined ? {} : { define: { ...normalized.options.transform.define } }), + ...(normalized.options.transform.dropLabels === undefined ? {} : { dropLabels: [...normalized.options.transform.dropLabels] }), + ...(normalized.options.transform.jsx === undefined ? {} : { jsx: normalized.options.transform.jsx }), + target: 'node20', + }; + /** Core 完整重建且不接受调用方 Plugin 的固定 input options。 */ + const inputOptions: EngineInputOptions = { + input: Object.freeze({ [entry.id]: entry.inputId }), + cwd: this.#projectRoot, + platform: 'node' as const, + tsconfig: false, + logLevel: 'silent' as const, + watch: false, + /** 只有已经规范化的 node: builtin 可以保持 external。 */ + external: (id: string) => id.startsWith('node:') && normalizeNodeBuiltin(id) === id, + ...(resolve === undefined ? {} : { resolve }), + treeshake: normalized.options.treeshake ?? true, + transform, + plugins: [ + managedSourceBoundaryPlugin({ sourceRoots: normalized.sourceRoots, workRoot, packages }), + portableNodePolicyPlugin(engine), + virtualSourcePlugin(normalized.virtualSources), + ], + }; + /** 单入口 create/generate/close 完全由 Core 接管。 */ + let bundle: Awaited> | undefined; + try { + bundle = await engine.create(inputOptions); + /** portable 唯一 output 参数完全由 Core 固定。 */ + const outputOptions: EngineOutputOptions = { + format: 'es' as const, + entryFileNames: 'main.mjs', + chunkFileNames: 'main.mjs', + assetFileNames: 'asset', + sourcemap: false, + codeSplitting: false, + comments: { legal: true }, + /** Rolldown 1.2.2 的非 whitespace 模式会注入绝对 module region;固定压缩空白但不改名/压缩表达式。 */ + minify: { compress: false, mangle: false }, + plugins: [moduleGraphPlugin(graph)], + }; + /** Rolldown generate-only 的原始内存输出。 */ + const raw = await bundle.generate(outputOptions); + /** 最终模块图、输出闭包和物理路径都在 Plugin 链外复核。 */ + const modules = await auditManagedModules(graph, scopes); + /** 固定单 Chunk 以及 residual/path policy 审计结果。 */ + const audited = auditPortableOutput(raw, entry.id, modules, [ + projectRoot, + workRoot, + ...normalized.sourceRoots, + ...[...packages.values()].map(dependency => dependency.root), + ]); + /** 当前 entry 的安全公开模块报告。 */ + const report = managedModuleReports(audited.modules); + reports.push(report); + /** main/license GeneratedAsset origin 使用的逻辑来源。 */ + const originInputs = report.map(module => module.id).sort(compareCodePoints); + /** main.mjs 只能落入当前 owner workDir。 */ + const mainRelative = safeRelativePath(`compile/${normalized.id}/${entry.id}/main.mjs`); + /** main.mjs 在 owner workDir 中的私有物理路径。 */ + const mainPhysical = this.#workDirectories.resolve(owner, workDirectory, mainRelative); + await fs.mkdir(path.dirname(mainPhysical), { recursive: true, mode: 0o700 }); + await fs.writeFile(mainPhysical, audited.bytes, { flag: 'wx', mode: 0o600 }); + /** 主 bundle 的不可伪造 GeneratedAssetRef。 */ + const mainAsset = await this.#assets.issueGenerated( + owner, + workDirectory, + mainRelative, + entry.mode, + { job: normalized.id, output: entry.id, profile: 'portable-node', kind: 'chunk', inputs: originInputs }, + ); + outputs.push(Object.freeze({ + type: 'chunk' as const, + outputId: entry.id, + fileName: 'main.mjs', + entryId: entry.id, + isEntry: true, + asset: mainAsset, + })); + /** 实际进入当前独立 bundle 的第三方包才生成相邻法律材料。 */ + const licenses = await collectCompilerLicenses(audited.modules, packages); + if (licenses.bytes !== undefined) { + /** 与 entry 相邻的稳定法律材料 workDir 路径。 */ + const licenseRelative = safeRelativePath(`compile/${normalized.id}/${entry.id}/THIRD_PARTY_LICENSES.txt`); + /** 法律材料在 owner workDir 中的私有物理路径。 */ + const licensePhysical = this.#workDirectories.resolve(owner, workDirectory, licenseRelative); + await fs.writeFile(licensePhysical, licenses.bytes, { flag: 'wx', mode: 0o600 }); + /** 法律材料自身的 GeneratedAssetRef。 */ + const licenseAsset = await this.#assets.issueGenerated( + owner, + workDirectory, + licenseRelative, + 0o644, + { job: normalized.id, output: entry.id, profile: 'portable-node', kind: 'licenses', inputs: [...originInputs, ...licenses.inputs] }, + ); + outputs.push(Object.freeze({ + type: 'licenses' as const, + outputId: entry.id, + fileName: 'THIRD_PARTY_LICENSES.txt', + entryId: entry.id, + isEntry: false, + asset: licenseAsset, + })); + } + /** 实际模块、manifest 与法律文件进入同一内部 Watch Registry。 */ + for (const observation of managedModuleWatchObservations(audited.modules)) + watchObservations.set(observation.path, observation); + for (const file of await bundle.watchFiles) { + /** 当前 Rolldown watch file 的授权后真实路径。 */ + const watched = await auditedWatchFile(file, audited.modules, scopes); + watchObservations.set(watched.path, watchObservations.get(watched.path) ?? watched); + } + for (const file of licenses.watchFiles) { + /** 当前法律文件所属的 resolver-proven package。 */ + const dependency = [...packages.values()].find(candidate => isInsidePath(candidate.root, file)); + watchObservations.set(file, Object.freeze({ + path: file, + type: 'file' as const, + ...(dependency === undefined ? {} : { identity: `package:${dependency.name}@${dependency.version}/${path.basename(file)}` }), + })); + } + } finally { + await bundle?.close(); + } + } + await this.#watch.replace(owner, `compiler/${normalized.id}`, [...watchObservations.values()]); + return Object.freeze({ + job: normalized.id, + profile: 'portable-node', + engine: Object.freeze({ name: engine.name, version: engine.version }), + outputs: Object.freeze(outputs), + modules: mergePortableModuleReports(reports), + }); + } +} diff --git a/packages/core/src/compiler/engine-loader.ts b/packages/core/src/compiler/engine-loader.ts new file mode 100644 index 0000000..7887c6a --- /dev/null +++ b/packages/core/src/compiler/engine-loader.ts @@ -0,0 +1,77 @@ +import type { + InputOptions, + OutputOptions, + Plugin, + RolldownBuild, + RolldownOutput, +} from 'rolldown'; + +/** SDK 从当前精确 Rolldown 依赖派生的输入参数。 */ +export type EngineInputOptions = InputOptions; + +/** SDK 从当前精确 Rolldown 依赖派生的输出参数。 */ +export type EngineOutputOptions = OutputOptions; + +/** SDK 从当前精确 Rolldown 依赖派生的 Plugin 结构。 */ +export type EnginePlugin = Plugin; + +/** Compiler Host 审计与签发使用的内存输出。 */ +export type EngineOutput = RolldownOutput; + +/** Compiler Host 唯一允许调用的 Rolldown bundle 能力。 */ +export interface ManagedEngineBuild { + /** 使用精确 Rolldown output options 生成内存产物。 */ + generate(options: OutputOptions): Promise; + /** 关闭当前原生 bundle 及 Plugin close lifecycle。 */ + close(): Promise; + readonly watchFiles: Promise; +} + +/** 动态加载后的精确 Rolldown 驱动器。 */ +export interface ManagedEngine { + readonly name: 'rolldown'; + readonly version: string; + /** 仅通过 rolldown() 创建一个受管 bundle。 */ + create(input: InputOptions): Promise; + /** 使用与 Compiler Host 相同 Rolldown 发行版解析 JS/TS 语法。 */ + parse(source: string, filename: string, language: 'js' | 'jsx' | 'ts' | 'tsx'): unknown; +} + +/** 进程内共享的 Rolldown 动态加载结果。 */ +let enginePromise: Promise | undefined; + +/** + * 延迟加载 Core 唯一 Rolldown 驱动。 + * + * @returns 版本直接来自当前 Rolldown 模块的最小驱动器。 + */ +export function loadManagedEngine(): Promise { + enginePromise ??= Promise.all([import('rolldown'), import('rolldown/parseAst')]).then(([module, parser]) => { + /** 解析器与 bundle 驱动在同一受管加载边界内取得,避免静态子路径依赖泄漏。 */ + const parseAst = parser.parseAst; + /** 驱动只暴露 rolldown、generate、close 与受管 watchFiles。 */ + const engine: ManagedEngine = { + name: 'rolldown', + version: module.VERSION, + /** 根据 Core 重建的 input options 创建原生 bundle。 */ + create: async (input): Promise => { + /** 原生 bundle 始终被收缩到 Host 内部能力面。 */ + const bundle: RolldownBuild = await module.rolldown(input); + return Object.freeze({ + /** 不暴露 write,只代理内存 generate。 */ + generate: (options: OutputOptions) => bundle.generate(options), + /** 不暴露原生 bundle identity 的关闭代理。 */ + close: () => bundle.close(), + /** 由 Host 在审计后统一消费 Rolldown watchFiles。 */ + get watchFiles() { + return bundle.watchFiles; + }, + }); + }, + /** portable policy 不引入第二套 parser。 */ + parse: (source, filename, language) => parseAst(source, { lang: language, sourceType: 'unambiguous' }, filename), + }; + return Object.freeze(engine); + }); + return enginePromise; +} diff --git a/packages/core/src/compiler/job-normalizer.ts b/packages/core/src/compiler/job-normalizer.ts new file mode 100644 index 0000000..7d27f03 --- /dev/null +++ b/packages/core/src/compiler/job-normalizer.ts @@ -0,0 +1,278 @@ +import { promises as fs } from 'node:fs'; +import type { + AssetMode, + SourceDirectoryRef, +} from '../contracts/services.js'; +import type { + CompileEntry, + CompileJob, + CompileProfile, +} from '../contracts/compiler.js'; +import { compareCodePoints } from '../security/path-policy.js'; +import { SourceRegistry } from '../services/sources.js'; + +/** Compiler job/output/entry 共用的稳定 ID 语法。 */ +const STABLE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** 虚拟模块公开 specifier 的稳定语法。 */ +const VIRTUAL_SPECIFIER = /^[a-z0-9]+(?:[-.:/][a-z0-9]+)*$/; + +/** Core 快照后的一个虚拟模块。 */ +export interface VirtualSource { + readonly code: string; + readonly resolveFrom?: string; +} + +/** 已授权并解析到物理边界的 Compile Entry。 */ +export interface NormalizedEntry { + readonly id: string; + readonly mode: AssetMode; + readonly inputId: string; + readonly sourceRoot: string; +} + +/** 同步快照、尚未执行物理 I/O 的 source Job。 */ +export interface PendingCompileSources { + readonly id: string; + readonly entries: readonly PendingEntry[]; + readonly scopes: readonly SourceDirectoryRef[]; + readonly virtualSources: ReadonlyMap; + readonly options: unknown; +} + +/** 已完成来源树复核的 Job 公共部分。 */ +export interface NormalizedCompileSources { + readonly id: string; + readonly entries: readonly NormalizedEntry[]; + readonly virtualSources: ReadonlyMap; + readonly sourceRoots: readonly string[]; +} + +/** 完成容器快照但尚未执行物理 I/O 的 entry。 */ +type PendingEntry = { + readonly id: string; + readonly mode: AssetMode; + readonly type: 'source'; + readonly source: CompileEntry & { readonly type: 'source' }; +} | { + readonly id: string; + readonly mode: AssetMode; + readonly type: 'virtual'; + readonly inputId: string; + readonly code: string; + readonly resolveFrom: SourceDirectoryRef; +}; + +/** + * 确认运行时对象不使用 accessor 或 Symbol 隐藏语义。 + * + * @param value 待检查对象。 + * @param label 稳定诊断标签。 + * @param optional 是否允许 undefined 并视为空对象。 + * @returns 全部自有 data property。 + */ +export function dataProperties( + value: unknown, + label: string, + optional = false, +): Record { + if (optional && value === undefined) + return {}; + if (typeof value !== 'object' || value === null || Array.isArray(value)) + throw new TypeError(`${label} must be an object.`); + if (Object.getOwnPropertySymbols(value).length > 0) + throw new TypeError(`${label} must not contain symbol properties.`); + /** descriptor 读取不会触发调用方 getter。 */ + const descriptors = Object.getOwnPropertyDescriptors(value); + for (const [field, descriptor] of Object.entries(descriptors)) { + if (!('value' in descriptor)) + throw new TypeError(`${label}.${field} must be a data property.`); + } + return descriptors; +} + +/** + * 校验稳定小写 kebab-case ID。 + * + * @param value 待验证文本。 + * @param label 诊断字段名。 + */ +export function assertStableId(value: unknown, label: string): asserts value is string { + if (typeof value !== 'string' || !STABLE_ID.test(value)) + throw new TypeError(`${label} must use lowercase kebab-case.`); +} + +/** + * 校验 Compile Entry mode。 + * + * @param value 调用方可选 mode。 + * @returns 只读模块默认 0644。 + */ +function entryMode(value: unknown): AssetMode { + /** 省略 mode 时使用非可执行默认值。 */ + const mode = value ?? 0o644; + if (mode !== 0o644 && mode !== 0o755) + throw new TypeError('Compile entry mode must be 0644 or 0755.'); + return mode; +} + +/** + * 在任何异步边界前快照 Compile Job 的公共来源结构。 + * + * @param owner 当前 owner。 + * @param profile 期望 Profile。 + * @param job 调用方 Job。 + * @param sources SourceRef 授权注册表。 + * @returns 不再引用调用方可变容器的待解析来源。 + */ +export function prepareCompileSources

( + owner: string, + profile: P, + job: CompileJob

, + sources: SourceRegistry, +): PendingCompileSources { + /** Job 顶层的完整 data property 集。 */ + const descriptors = dataProperties(job, 'Compile job'); + for (const field of Object.keys(descriptors)) { + if (!new Set(['id', 'profile', 'entries', 'sourceScopes', 'virtualModules', 'options']).has(field)) + throw new TypeError(`Compile job.${field} is unknown.`); + } + assertStableId(descriptors.id?.value, 'Compile job id'); + if (descriptors.profile?.value !== profile) + throw new TypeError(`Compiler normalization expected profile "${profile}".`); + /** 命名入口的完整 data property 集。 */ + const entryDescriptors = dataProperties(descriptors.entries?.value, 'Compile job entries'); + if (Object.keys(entryDescriptors).length === 0) + throw new TypeError('Compile job entries must not be empty.'); + /** 输入 entry 在任何 await 前完成容器快照与 Ref identity 授权。 */ + const entries: PendingEntry[] = []; + for (const id of Object.keys(entryDescriptors).sort(compareCodePoints)) { + assertStableId(id, 'Compile entry id'); + /** 当前入口的完整 data property 集。 */ + const entry = dataProperties(entryDescriptors[id]!.value, `Compile entry "${id}"`); + for (const field of Object.keys(entry)) { + if (!new Set(['type', 'source', 'code', 'resolveFrom', 'mode']).has(field)) + throw new TypeError(`Compile entry "${id}".${field} is unknown.`); + } + /** 当前入口经校验的交付 mode。 */ + const mode = entryMode(entry.mode?.value); + if (entry.type?.value === 'source') { + /** 与调用方 entry 容器解除引用的 SourceRef 请求。 */ + const source = Object.freeze({ type: 'source' as const, source: entry.source?.value as never, mode }); + sources.authorizeFile(owner, source.source); + entries.push(Object.freeze({ id, mode, type: 'source' as const, source })); + } else if (entry.type?.value === 'virtual') { + if (typeof entry.code?.value !== 'string') + throw new TypeError(`Compile entry "${id}".code must be a string.`); + /** 虚拟 entry 相对 import 使用的受权目录 ref。 */ + const resolveFrom = entry.resolveFrom?.value as SourceDirectoryRef; + sources.authorizeDirectory(owner, resolveFrom); + entries.push(Object.freeze({ + id, + mode, + type: 'virtual' as const, + inputId: `\0acplugin:entry:${id}`, + code: entry.code.value, + resolveFrom, + })); + } else { + throw new TypeError(`Compile entry "${id}".type must be source or virtual.`); + } + } + /** 来源 scope ref 数组在任何 await 前复制并授权。 */ + const scopes: SourceDirectoryRef[] = []; + if (descriptors.sourceScopes?.value !== undefined) { + if (!Array.isArray(descriptors.sourceScopes.value)) + throw new TypeError('Compile job sourceScopes must be an array.'); + for (const scope of [...descriptors.sourceScopes.value]) { + sources.authorizeDirectory(owner, scope); + scopes.push(scope); + } + } + /** 虚拟模块字典同样只接受稳定 data properties。 */ + const virtualSources = new Map(); + if (descriptors.virtualModules?.value !== undefined) { + /** 虚拟模块的完整 data property 集。 */ + const modules = dataProperties(descriptors.virtualModules.value, 'Compile job virtualModules'); + for (const specifier of Object.keys(modules).sort(compareCodePoints)) { + if (!VIRTUAL_SPECIFIER.test(specifier)) + throw new TypeError(`Virtual module specifier "${specifier}" is invalid.`); + if (typeof modules[specifier]!.value !== 'string') + throw new TypeError(`Virtual module "${specifier}" must contain string code.`); + virtualSources.set(`\0acplugin:module:${specifier}`, Object.freeze({ code: modules[specifier]!.value as string })); + } + } + return Object.freeze({ + id: descriptors.id.value, + entries: Object.freeze(entries), + scopes: Object.freeze(scopes), + virtualSources: new Map(virtualSources), + options: descriptors.options?.value, + }); +} + +/** + * 复核作者树、SourceRef 指纹并解析 Rolldown 所需物理来源。 + * + * @param owner 当前 owner。 + * @param pending 同步快照后的 Job 来源。 + * @param sources SourceRef 授权注册表。 + * @returns 已完成全部 I/O 边界检查的来源。 + */ +export async function resolveCompileSources( + owner: string, + pending: PendingCompileSources, + sources: SourceRegistry, +): Promise { + /** 当前 Job 的完整物理来源根。 */ + const sourceRoots = new Set(); + /** 本次已递归检查的作者物理根。 */ + const validatedRoots = new Set(); + /** Rolldown 可消费的最终入口。 */ + const entries: NormalizedEntry[] = []; + /** 虚拟源码需要补上异步解析得到的物理 resolveFrom。 */ + const virtualSources = new Map(pending.virtualSources); + for (const entry of pending.entries) { + if (entry.type === 'source') { + /** 已复核指纹的物理文件记录。 */ + const record = await sources.validatedFile(owner, entry.source.source); + if (!validatedRoots.has(record.root)) { + await sources.validateFileTree(owner, entry.source.source); + validatedRoots.add(record.root); + } + /** Rolldown 读取真实路径,报告仍只使用 Registry 中的相对路径。 */ + const inputId = await fs.realpath(record.physicalPath); + /** 当前 source 授权根的真实物理路径。 */ + const sourceRoot = await fs.realpath(record.root); + sourceRoots.add(sourceRoot); + entries.push(Object.freeze({ id: entry.id, mode: entry.mode, inputId, sourceRoot })); + } else { + /** 虚拟 entry 的已授权目录记录。 */ + const record = sources.authorizeDirectory(owner, entry.resolveFrom); + if (!validatedRoots.has(record.root)) { + await sources.validateTree(owner, entry.resolveFrom); + validatedRoots.add(record.root); + } + /** 虚拟 entry 相对 import 的真实授权根。 */ + const sourceRoot = await fs.realpath(record.physicalPath); + sourceRoots.add(sourceRoot); + virtualSources.set(entry.inputId, Object.freeze({ code: entry.code, resolveFrom: sourceRoot })); + entries.push(Object.freeze({ id: entry.id, mode: entry.mode, inputId: entry.inputId, sourceRoot })); + } + } + for (const scope of pending.scopes) { + /** 额外 source scope 必须同样递归拒绝 symlink 和特殊文件。 */ + const record = sources.authorizeDirectory(owner, scope); + if (!validatedRoots.has(record.root)) { + await sources.validateTree(owner, scope); + validatedRoots.add(record.root); + } + sourceRoots.add(await fs.realpath(record.physicalPath)); + } + return Object.freeze({ + id: pending.id, + entries: Object.freeze(entries), + virtualSources: new Map(virtualSources), + sourceRoots: Object.freeze([...sourceRoots].sort(compareCodePoints)), + }); +} diff --git a/packages/core/src/compiler/license-pipeline.ts b/packages/core/src/compiler/license-pipeline.ts new file mode 100644 index 0000000..4e8c4ce --- /dev/null +++ b/packages/core/src/compiler/license-pipeline.ts @@ -0,0 +1,129 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import parseSpdxExpression from 'spdx-expression-parse'; +import { compareCodePoints } from '../security/path-policy.js'; +import type { AuditedModule } from './managed/auditor.js'; +import type { ManagedPackageScope } from './managed/boundary.js'; + +/** 单个法律材料文件的固定读取上限。 */ +const MAX_LEGAL_FILE_BYTES = 4 * 1024 * 1024; + +/** Core 认可的 package 根法律文件名。 */ +const LEGAL_FILE = /^(?:licen[cs]e|notice|copying)(?:[._-].*)?$/iu; + +/** Compiler license pipeline 的确定性结果。 */ +export interface CompilerLicenseResult { + readonly bytes?: Uint8Array; + readonly inputs: readonly string[]; + readonly watchFiles: readonly string[]; +} + +/** + * 验证 manifest license 是完整 SPDX expression。 + * + * @param value package.json license 字段。 + * @param identity 已脱敏 package 身份。 + * @returns 原始合法 expression。 + */ +function spdxLicense(value: unknown, identity: string): string { + if (typeof value !== 'string' || value.length === 0) + throw new Error(`Bundled dependency "${identity}" must declare a license SPDX expression.`); + try { + parseSpdxExpression(value); + } catch { + throw new Error(`Bundled dependency "${identity}" declares an invalid license SPDX expression.`); + } + return value; +} + +/** + * 收集当前独立 entry 实际模块图中的第三方法律材料。 + * + * @param modules 最终通过授权审计的模块图。 + * @param packages resolver 证明的 package 边界。 + * @returns 非空依赖时的固定文本和全部 watch/origin 输入。 + */ +export async function collectCompilerLicenses( + modules: readonly AuditedModule[], + packages: ReadonlyMap, +): Promise { + /** 只收集确实进入最终 bundle graph 的 package root。 */ + const usedRoots = new Set(modules + .filter(module => module.kind === 'package') + .map(module => [...packages.values()].find(candidate => module.physicalId === candidate.root + || module.physicalId.startsWith(`${candidate.root}${path.sep}`))?.root) + .filter((root): root is string => root !== undefined)); + if (usedRoots.size === 0) + return Object.freeze({ inputs: Object.freeze([]), watchFiles: Object.freeze([]) }); + /** package 身份排序不依赖包管理器物理布局。 */ + const dependencies = [...usedRoots] + .map(root => packages.get(root)!) + .sort((left, right) => compareCodePoints(`${left.name}@${left.version}`, `${right.name}@${right.version}`)); + /** 每个 package 独立形成一个稳定 section。 */ + const sections: string[] = []; + /** manifest/legal 文件同时是 watch 和 structured origin 输入。 */ + const watchFiles: string[] = []; + /** GeneratedAsset structured provenance 使用的安全 package 引用。 */ + const inputs: string[] = []; + for (const dependency of dependencies) { + /** package identity 不包含磁盘位置。 */ + const identity = `${dependency.name}@${dependency.version}`; + /** 当前 package 根 manifest 物理路径。 */ + const manifestPath = path.join(dependency.root, 'package.json'); + /** resolver 已证明 manifest;license 阶段重新拒绝 symlink/特殊文件。 */ + const manifestStat = await fs.lstat(manifestPath); + if (!manifestStat.isFile() || manifestStat.isSymbolicLink()) + throw new Error(`Bundled dependency "${identity}" has an unsafe package manifest.`); + /** JSON parse 错误统一收敛为不泄露绝对路径的诊断。 */ + let manifest: { readonly name?: unknown; readonly version?: unknown; readonly license?: unknown }; + try { + manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as typeof manifest; + } catch { + throw new Error(`Bundled dependency "${identity}" has an unreadable package manifest.`); + } + if (manifest.name !== dependency.name || manifest.version !== dependency.version) + throw new Error(`Bundled dependency "${identity}" changed identity during compilation.`); + /** 经过 parser 完整确认的 SPDX expression。 */ + const license = spdxLicense(manifest.license, identity); + /** 只枚举 package root,避免把嵌套源码中任意文件解释为法律材料。 */ + const directoryEntries = (await fs.readdir(dependency.root, { withFileTypes: true })) + .filter(entry => LEGAL_FILE.test(entry.name)) + .sort((left, right) => compareCodePoints(left.name, right.name)); + if (directoryEntries.length === 0) + throw new Error(`Bundled dependency "${identity}" does not contain license or notice evidence.`); + /** 同一 package 的材料按文件名稳定连接。 */ + const materials: string[] = []; + for (const entry of directoryEntries) { + /** 当前法律材料的 package-root 直属物理路径。 */ + const legalPath = path.join(dependency.root, entry.name); + /** 法律材料必须是普通非 symlink 文件且大小受限。 */ + const stat = await fs.lstat(legalPath); + if (!entry.isFile() || !stat.isFile() || stat.isSymbolicLink()) + throw new Error(`Bundled dependency "${identity}" has unsafe legal material.`); + if (stat.size === 0 || stat.size > MAX_LEGAL_FILE_BYTES) + throw new Error(`Bundled dependency "${identity}" has missing or oversized legal material.`); + /** CRLF 只规范成 LF;不裁剪或改写法律正文。 */ + const body = (await fs.readFile(legalPath, 'utf8')).replace(/\r\n?/gu, '\n'); + if (body.trim().length === 0) + throw new Error(`Bundled dependency "${identity}" has empty legal material.`); + materials.push(`--- ${entry.name} ---\n${body.endsWith('\n') ? body : `${body}\n`}`); + watchFiles.push(legalPath); + inputs.push(`package:${identity}/${entry.name}`); + } + watchFiles.push(manifestPath); + inputs.push(`package:${identity}/package.json`); + sections.push([ + `Package: ${identity}`, + `License: ${license}`, + '', + ...materials, + ].join('\n')); + } + /** 固定 heading/分隔与最终换行,不包含生成时间或物理路径。 */ + const text = `THIRD-PARTY LICENSES\n\n${sections.join('\n========================================\n\n')}`; + return Object.freeze({ + bytes: new TextEncoder().encode(text.endsWith('\n') ? text : `${text}\n`), + inputs: Object.freeze([...new Set(inputs)].sort(compareCodePoints)), + watchFiles: Object.freeze([...new Set(watchFiles)].sort(compareCodePoints)), + }); +} diff --git a/packages/core/src/compiler/managed/auditor.ts b/packages/core/src/compiler/managed/auditor.ts new file mode 100644 index 0000000..26dee59 --- /dev/null +++ b/packages/core/src/compiler/managed/auditor.ts @@ -0,0 +1,252 @@ +/** managed-rolldown 输出和模块图审计。 */ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { + CompileModuleReport, + ManagedRolldownCompileOptions, +} from '../../contracts/compiler.js'; +import { compareCodePoints, isInsidePath, safeRelativePath } from '../../security/path-policy.js'; +import type { EngineOutput } from '../engine-loader.js'; +import type { ManagedPackageScope } from './boundary.js'; +import { assertNoPhysicalPathBytes } from '../physical-path-auditor.js'; + +/** Core 从 Rolldown ModuleInfo 仅采样的审计字段。 */ +export interface EngineModuleSnapshot { + readonly importedIds: readonly string[]; + readonly dynamicallyImportedIds: readonly string[]; + readonly importers: readonly string[]; + readonly dynamicImporters: readonly string[]; +} + +/** 模块图审计需要的安全来源根。 */ +export interface ManagedAuditScopes { + readonly projectRoot: string; + readonly sourceRoots: readonly string[]; + readonly workRoot: string; + readonly packages: ReadonlyMap; +} + +/** 通过 module graph 审计的内部节点。 */ +export interface AuditedModule { + readonly physicalId: string; + readonly logicalId: string; + readonly kind: CompileModuleReport['kind']; + readonly inputs: readonly string[]; + readonly importedBy: readonly string[]; +} + +/** + * 返回模块图对应的内部物理 watch 观察。 + * + * @param modules 已完成来源授权和逻辑身份脱敏的模块。 + * @returns project/source 使用物理文件,package 同时附带安全 identity。 + */ +export function managedModuleWatchObservations( + modules: readonly AuditedModule[], +): readonly { readonly path: string; readonly type: 'file'; readonly identity?: string }[] { + return Object.freeze(modules + .filter(module => path.isAbsolute(module.physicalId.replace(/\?.*$/u, ''))) + .map(module => Object.freeze({ + path: module.physicalId.replace(/\?.*$/u, ''), + type: 'file' as const, + ...(module.kind === 'package' ? { identity: module.logicalId } : {}), + })) + .sort((left, right) => compareCodePoints(left.path, right.path))); +} + +/** 验证后的一个 Rolldown 输出文件。 */ +export interface AuditedOutput { + readonly type: 'chunk' | 'asset'; + readonly fileName: string; + readonly entryId?: string; + readonly isEntry: boolean; + readonly bytes: Uint8Array; +} + +/** + * 判断一个 ID 是否为 Rolldown 或 Plugin 虚拟模块。 + * + * @param id Rolldown 模块 ID。 + * @returns 不携带物理绝对路径时返回 true。 + */ +function isVirtualId(id: string): boolean { + return id.startsWith('\0') || !path.isAbsolute(id.replace(/\?.*$/u, '')); +} + +/** + * 把虚拟模块 ID 收敛为稳定报告身份。 + * + * @param id 原始虚拟 ID。 + * @returns 不包含 NUL、空白或绝对路径的逻辑 ID。 + */ +function virtualIdentity(id: string): string { + /** Rolldown 内部 NUL 只是虚拟前缀,不进入稳定报告。 */ + const value = id.replace(/^\0+/u, '').replace(/\?.*$/u, ''); + if (!/^[A-Za-z0-9@._:/-]+$/.test(value) || value.includes('..') || value.startsWith('/')) + throw new Error('Managed Rolldown produced an unsafe virtual module identity.'); + return `virtual:${value.replace(/^acplugin:/u, '')}`; +} + +/** + * 把一个物理/虚拟模块 ID 分类为安全逻辑身份。 + * + * @param id Rolldown 模块 ID。 + * @param scopes 当前 owner 授权边界。 + * @returns 报告 ID 与种类。 + */ +async function logicalModuleIdentity( + id: string, + scopes: ManagedAuditScopes, +): Promise<{ readonly id: string; readonly kind: CompileModuleReport['kind'] }> { + if (isVirtualId(id)) + return Object.freeze({ id: virtualIdentity(id), kind: 'virtual' as const }); + /** query 不参与物理路径边界判定。 */ + const physical = path.normalize(id.replace(/\?.*$/u, '')); + /** 作者来源与 owner workDir 均使用 project-relative 或 owner-local 逻辑 ID。 */ + if (scopes.sourceRoots.some(root => isInsidePath(root, physical))) { + return Object.freeze({ + id: path.relative(scopes.projectRoot, physical).split(path.sep).join('/'), + kind: 'source' as const, + }); + } + if (isInsidePath(scopes.workRoot, physical)) { + return Object.freeze({ + id: `virtual:work/${path.relative(scopes.workRoot, physical).split(path.sep).join('/')}`, + kind: 'virtual' as const, + }); + } + /** 其他物理模块必须能归属正常 package manager 依赖。 */ + const real = await fs.realpath(physical).catch(() => physical); + /** 与 resolver 证明集匹配的依赖 package。 */ + const dependency = [...scopes.packages.values()].find(scope => isInsidePath(scope.root, real)); + if (dependency === undefined) + throw new Error('Managed Rolldown module graph escaped authorized sources without a package boundary.'); + /** package 内相对子路径保留可审计性。 */ + const subpath = path.relative(dependency.root, real).split(path.sep).join('/'); + return Object.freeze({ + id: `package:${dependency.name}@${dependency.version}${subpath.length === 0 ? '' : `/${subpath}`}`, + kind: 'package' as const, + }); +} + +/** + * 校验并脱敏 Rolldown 最终模块图。 + * + * @param graph Core 审计 Plugin 捕获的原始图节点。 + * @param scopes 当前 owner 授权边界。 + * @returns 稳定排序的私有审计节点。 + */ +export async function auditManagedModules( + graph: ReadonlyMap, + scopes: ManagedAuditScopes, +): Promise { + /** 原始 ID 到脱敏身份的完整映射。 */ + const identities = new Map(); + for (const id of graph.keys()) + identities.set(id, await logicalModuleIdentity(id, scopes)); + /** 节点引用边仅保留已在最终图中审计的模块。 */ + const modules: AuditedModule[] = []; + for (const [physicalId, info] of graph) { + /** 当前物理节点的脱敏身份。 */ + const identity = identities.get(physicalId)!; + /** 当前节点的静态与动态输入边。 */ + const inputs = [...info.importedIds, ...info.dynamicallyImportedIds] + .map(id => identities.get(id)?.id) + .filter((id): id is string => id !== undefined); + /** 当前节点的静态与动态反向边。 */ + const importedBy = [...info.importers, ...info.dynamicImporters] + .map(id => identities.get(id)?.id) + .filter((id): id is string => id !== undefined); + modules.push(Object.freeze({ + physicalId, + logicalId: identity.id, + kind: identity.kind, + inputs: Object.freeze([...new Set(inputs)].sort(compareCodePoints)), + importedBy: Object.freeze([...new Set(importedBy)].sort(compareCodePoints)), + })); + } + return Object.freeze(modules.sort((left, right) => compareCodePoints(left.logicalId, right.logicalId))); +} + +/** + * 验证 Rolldown 输出路径、来源映射和原始字节。 + * + * @param output Rolldown generate() 返回值。 + * @param modules 同一 build object 的已审计模块。 + * @param policy managed Profile 策略。 + * @returns 可写入 owner workDir 并签发的文件快照。 + */ +export function auditManagedOutput( + output: EngineOutput, + modules: readonly AuditedModule[], + policy: ManagedRolldownCompileOptions['policy'], + physicalRoots: readonly string[], +): readonly AuditedOutput[] { + /** 所有输出使用 exact/case/NFC 折叠键检测跨文件系统冲突。 */ + const paths = new Map(); + /** 当前 output 的完整文件集合用于静态/动态 chunk 闭包检查。 */ + const knownFiles = new Set(output.output.map(item => safeRelativePath(item.fileName))); + /** 当前 output 已通过的文件快照。 */ + const audited: AuditedOutput[] = []; + /** 最终模块图的原始 ID 集合。 */ + const moduleIds = new Set(modules.map(module => module.physicalId)); + for (const item of output.output) { + /** 不经 normalize 折叠的安全 Rolldown fileName。 */ + const fileName = safeRelativePath(item.fileName); + /** 跨大小写/NFC 文件系统的冲突键。 */ + const collisionKey = fileName.normalize('NFC').toLowerCase(); + /** 已占用同一折叠键的先前路径。 */ + const previous = paths.get(collisionKey); + if (previous !== undefined) + throw new Error(`Managed Rolldown output path collision between "${previous}" and "${fileName}".`); + paths.set(collisionKey, fileName); + if (item.type === 'chunk') { + /** Chunk 声明的每个模块都必须已在独立图审计中通过。 */ + for (const id of [...item.moduleIds, ...Object.keys(item.modules)]) { + if (!moduleIds.has(id)) + throw new Error('Managed Rolldown output references a module outside the audited graph.'); + } + if (policy?.nativeAddons === 'reject' + && (item.moduleIds.some(id => /\.node(?:[?#]|$)/u.test(id)) || /\.node(?:[?#'"`]|$)/u.test(item.code))) + throw new Error('Managed Rolldown output contains a native addon reference rejected by policy.'); + if (policy?.unresolvedImports === 'reject' + && [...item.imports, ...item.dynamicImports].some(id => !knownFiles.has(id) && !id.startsWith('node:'))) + throw new Error('Managed Rolldown output contains an unresolved import rejected by policy.'); + /** Chunk 字节在任何 GeneratedAssetRef 签发前执行统一物理根审计。 */ + const bytes = new TextEncoder().encode(item.code); + if (policy?.deterministic === true) + assertNoPhysicalPathBytes(bytes, physicalRoots, 'Managed Rolldown deterministic output contains an absolute build path.'); + audited.push(Object.freeze({ + type: 'chunk' as const, + fileName, + ...(item.isEntry ? { entryId: item.name } : {}), + isEntry: item.isEntry, + bytes, + })); + } else { + /** Asset source 必须复制,不与 Rolldown external-memory handle 共享。 */ + const bytes = typeof item.source === 'string' + ? new TextEncoder().encode(item.source) + : Uint8Array.from(item.source); + if (policy?.deterministic === true) + assertNoPhysicalPathBytes(bytes, physicalRoots, 'Managed Rolldown deterministic output contains an absolute build path.'); + audited.push(Object.freeze({ type: 'asset' as const, fileName, isEntry: false, bytes })); + } + } + return Object.freeze(audited.sort((left, right) => compareCodePoints(left.fileName, right.fileName))); +} + +/** + * 把私有审计节点投影为 SDK 模块报告。 + * + * @param modules 已脱敏私有节点。 + * @returns 不包含物理 ID 的公开结果。 + */ +export function managedModuleReports(modules: readonly AuditedModule[]): readonly CompileModuleReport[] { + return Object.freeze(modules.map(module => Object.freeze({ + id: module.logicalId, + kind: module.kind, + inputs: module.inputs, + importedBy: module.importedBy, + }))); +} diff --git a/packages/core/src/compiler/managed/boundary.ts b/packages/core/src/compiler/managed/boundary.ts new file mode 100644 index 0000000..d753713 --- /dev/null +++ b/packages/core/src/compiler/managed/boundary.ts @@ -0,0 +1,167 @@ +/** managed-rolldown Plugin capability 边界。 */ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { ManagedRolldownPlugin } from '../../contracts/compiler.js'; +import { isInsidePath } from '../../security/path-policy.js'; + +/** 由正常 bare import 解析证明的 package 边界。 */ +export interface ManagedPackageScope { + readonly name: string; + readonly version: string; + readonly root: string; +} + +/** managed resolver 需要的 owner 物理来源边界。 */ +export interface ManagedResolutionScopes { + readonly sourceRoots: readonly string[]; + readonly workRoot: string; + readonly packages: Map; +} + +/** + * 把 Rolldown module ID 收窄为物理绝对路径。 + * + * @param id Rolldown 模块 ID。 + * @returns 去除 query 的绝对路径,虚拟/裸 ID 返回 undefined。 + */ +function moduleFile(id: string): string | undefined { + /** query 不属于物理文件身份。 */ + const value = id.replace(/\?.*$/u, ''); + return path.isAbsolute(value) ? path.normalize(value) : undefined; +} + +/** + * 判断 import specifier 是否不携带本地路径语义。 + * + * @param source import specifier。 + * @returns bare package/imports specifier 返回 true。 + */ +function isBareSpecifier(source: string): boolean { + return !source.startsWith('.') + && !source.startsWith('/') + && !source.startsWith('file:') + && !path.isAbsolute(source) + && !source.startsWith('\0'); +} + +/** + * 从 bare specifier 提取预期 package name。 + * + * @param source bare import specifier。 + * @returns scoped/unscoped 根包名,package imports 返回 undefined。 + */ +function barePackageName(source: string): string | undefined { + if (!isBareSpecifier(source) || source.startsWith('#')) + return undefined; + /** scoped 与 unscoped package 的路径分段。 */ + const segments = source.split('/'); + return source.startsWith('@') + ? segments.length >= 2 ? `${segments[0]}/${segments[1]}` : undefined + : segments[0] || undefined; +} + +/** + * 查找已解析模块最近的 package 身份。 + * + * @param file package manager 解析后的文件。 + * @returns 有 name/version 的普通 manifest 边界。 + */ +export async function packageScope(file: string): Promise { + /** package manager symlink 解析后的真实模块路径。 */ + const real = await fs.realpath(file).catch(() => path.normalize(file)); + /** 从模块目录开始向上查找最近 manifest。 */ + let directory = path.dirname(real); + while (true) { + /** 当前候选 package manifest。 */ + const manifest = path.join(directory, 'package.json'); + try { + /** manifest 必须是非 symlink 普通文件。 */ + const stat = await fs.lstat(manifest); + if (!stat.isFile() || stat.isSymbolicLink()) + return undefined; + /** 依赖授权只使用稳定身份字段。 */ + const data = JSON.parse(await fs.readFile(manifest, 'utf8')) as { readonly name?: unknown; readonly version?: unknown }; + if (typeof data.name === 'string' && data.name.length > 0 + && typeof data.version === 'string' && data.version.length > 0) { + return Object.freeze({ name: data.name, version: data.version, root: directory }); + } + /** dist/esm/package.json 等仅声明 type 的嵌套 manifest 不是 package identity 边界。 */ + } catch /** error 只区分 manifest 不存在与无法读取。 */ (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') + return undefined; + } + /** 父目录用于稳定终止向上查找。 */ + const parent = path.dirname(directory); + if (parent === directory) + return undefined; + directory = parent; + } +} + +/** + * 查找已批准的 importer package。 + * + * @param packages 当前 Job 已证明 package 集合。 + * @param importerFile importer 真实路径。 + * @returns 包含 importer 的 package 边界。 + */ +function importerPackage( + packages: ReadonlyMap, + importerFile: string | undefined, +): ManagedPackageScope | undefined { + if (importerFile === undefined) + return undefined; + return [...packages.values()].find(scope => isInsidePath(scope.root, importerFile)); +} + +/** + * 建立 Core 插入的受管解析边界。 + * + * Plugin 先调用后续 trusted resolver,再对结果建立 source/work/package + * 授权。最终 module audit 仍会在 generate() 返回后独立复核此集合。 + * + * @param scopes 当前 Job 的可变 package 证明集与固定来源根。 + * @returns 必须位于调用方 Plugin 之前的 Core resolver。 + */ +export function managedSourceBoundaryPlugin(scopes: ManagedResolutionScopes): ManagedRolldownPlugin { + return Object.freeze({ + name: 'acplugin-source-boundary', + resolveId: { + order: 'pre' as const, + /** 解析后立即建立 source/work/package 证明。 */ + async handler(source, importer, options) { + /** skipSelf 保留用户 Plugin、Core virtual Plugin 和 Rolldown resolver 语义。 */ + const resolved = await this.resolve(source, importer, { ...options, skipSelf: true }); + if (resolved === null || resolved.external) + return resolved; + /** 已解析结果中的物理文件。 */ + const file = moduleFile(resolved.id); + if (file === undefined) + return resolved; + /** package manager symlink 解析后的真实模块路径。 */ + const real = await fs.realpath(file).catch(() => path.normalize(file)); + if (scopes.sourceRoots.some(root => isInsidePath(root, real)) || isInsidePath(scopes.workRoot, real)) + return { ...resolved, id: real }; + /** 当前 importer 的可选真实文件。 */ + const importerFile = importer === undefined + ? undefined + : await fs.realpath(moduleFile(importer) ?? '').catch(() => moduleFile(importer)); + /** importer 已经证明的 package 边界。 */ + const parentPackage = importerPackage(scopes.packages, importerFile); + if (parentPackage !== undefined && isInsidePath(parentPackage.root, real)) + return { ...resolved, id: real }; + /** bare specifier 显式声明的 package 名。 */ + const expectedName = barePackageName(source); + if (expectedName !== undefined) { + /** 已解析模块最近的 manifest 身份。 */ + const dependency = await packageScope(real); + if (dependency !== undefined && dependency.name === expectedName) { + scopes.packages.set(dependency.root, dependency); + return { ...resolved, id: real }; + } + } + throw new Error('Managed Rolldown resolution escaped authorized sources without a package dependency boundary.'); + }, + }, + }); +} diff --git a/packages/core/src/compiler/managed/options.ts b/packages/core/src/compiler/managed/options.ts new file mode 100644 index 0000000..a828358 --- /dev/null +++ b/packages/core/src/compiler/managed/options.ts @@ -0,0 +1,508 @@ +/** managed-rolldown options 的严格数据规范化。 */ +import type { + EngineInputOptions, + EngineOutputOptions, + EnginePlugin, +} from '../engine-loader.js'; + +/** Rolldown 1.2.2 中 managed Profile 显式支持的 input 字段。 */ +const INPUT_FIELDS = new Set([ + 'external', + 'resolve', + 'platform', + 'shimMissingExports', + 'treeshake', + 'onLog', + 'moduleTypes', + 'experimental', + 'transform', + 'checks', + 'makeAbsoluteExternalsRelative', + 'preserveEntrySignatures', + 'optimization', + 'context', +]); + +/** 由 Core 接管或属于另一条 write/watch/devtools 生命周期的 input 字段。 */ +const FORBIDDEN_INPUT_FIELDS = new Set(['input', 'cwd', 'logLevel', 'onwarn', 'watch', 'devtools', 'output']); + +/** Rolldown 1.2.2 中 generate() 可使用的 output 字段。 */ +const OUTPUT_FIELDS = new Set([ + 'exports', + 'hashCharacters', + 'format', + 'sourcemap', + 'sourcemapBaseUrl', + 'sourcemapFileNames', + 'sourcemapDebugIds', + 'sourcemapIgnoreList', + 'sourcemapPathTransform', + 'sourcemapExcludeSources', + 'banner', + 'footer', + 'postBanner', + 'postFooter', + 'intro', + 'outro', + 'extend', + 'esModule', + 'assetFileNames', + 'entryFileNames', + 'chunkFileNames', + 'sanitizeFileName', + 'minify', + 'name', + 'globals', + 'paths', + 'generatedCode', + 'externalLiveBindings', + 'inlineDynamicImports', + 'dynamicImportInCjs', + 'manualChunks', + 'codeSplitting', + 'advancedChunks', + 'legalComments', + 'comments', + 'polyfillRequire', + 'hoistTransitiveImports', + 'preserveModules', + 'virtualDirname', + 'preserveModulesRoot', + 'topLevelVar', + 'minifyInternalExports', + 'keepNames', + 'strictExecutionOrder', + 'strict', +]); + +/** Core 永远不会传给 generate() 的物理输出字段。 */ +const FORBIDDEN_OUTPUT_FIELDS = new Set(['dir', 'file']); + +/** Rolldown Plugin 的全部当前公开 hook。 */ +const PLUGIN_HOOKS = new Set([ + 'onLog', + 'options', + 'outputOptions', + 'buildStart', + 'resolveId', + 'resolveDynamicImport', + 'load', + 'transform', + 'moduleParsed', + 'buildEnd', + 'renderStart', + 'renderChunk', + 'augmentChunkHash', + 'resolveFileUrl', + 'renderError', + 'generateBundle', + 'closeBundle', + 'banner', + 'footer', + 'intro', + 'outro', +]); + +/** 接受后却不会在 generate-only Host 执行的 Plugin hook。 */ +const FORBIDDEN_PLUGIN_HOOKS = new Set(['writeBundle', 'watchChange', 'closeWatcher']); + +/** Plugin 非 hook 元数据字段。 */ +const PLUGIN_FIELDS = new Set(['name', 'version', 'meta', 'api']); + +/** 当前 managed Profile 拒绝依赖 watch/direct-write 语义的实验字段。 */ +const FORBIDDEN_EXPERIMENTAL_FIELDS = new Set(['devMode', 'incrementalBuild']); + +/** options hook 不得原地或通过返回值改写的 Core 输入边界。 */ +const PROTECTED_INPUT_HOOK_FIELDS = new Set(['input', 'cwd', 'plugins', 'logLevel', 'onwarn', 'watch', 'devtools', 'output', 'tsconfig']); + +/** outputOptions hook 不得原地或通过返回值改写的 Core 输出边界。 */ +const PROTECTED_OUTPUT_HOOK_FIELDS = new Set(['dir', 'file', 'plugins']); + +/** + * 仅读取已验证的 data property。 + * + * @param input 待检查结构。 + * @param label 稳定诊断标签。 + * @returns 不包含 accessor 或 Symbol 语义的字段集。 + */ +function dataProperties(input: unknown, label: string): Record { + if (typeof input !== 'object' || input === null || Array.isArray(input)) + throw new TypeError(`${label} must be an object.`); + if (Object.getOwnPropertySymbols(input).length > 0) + throw new TypeError(`${label} must not contain symbol properties.`); + /** 完整 descriptor 集避免在验证前触发 getter。 */ + const descriptors = Object.getOwnPropertyDescriptors(input); + for (const [field, descriptor] of Object.entries(descriptors)) { + if (!('value' in descriptor)) + throw new TypeError(`${label}.${field} must be a data property.`); + } + return descriptors; +} + +/** + * 复制一个 Rolldown 参数值,与调用方所持嵌套容器解除引用。 + * + * @param value 待快照值。 + * @param label 当前字段路径。 + * @param seen 循环与重复引用记录。 + * @returns 保留函数与可信实例 identity 的冻结结构副本。 + */ +function snapshotValue(value: unknown, label: string, seen = new Map()): unknown { + if (value === null || typeof value !== 'object') + return value; + /** RegExp 是 Rolldown filter 中的值对象,需要复制 lastIndex 而不是冻结原对象。 */ + if (value instanceof RegExp) { + /** 保留 source/flags/lastIndex 的新 RegExp identity。 */ + const copy = new RegExp(value.source, value.flags); + copy.lastIndex = value.lastIndex; + return copy; + } + /** Uint8Array 是 output/plugin 可用的精确字节值。 */ + if (value instanceof Uint8Array) + return Uint8Array.from(value); + /** URL 与其他内建或 Plugin 实例保留可信 identity。 */ + const prototype = Object.getPrototypeOf(value); + if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) + return value; + /** 已复制容器用于保留重复引用和终止循环。 */ + const existing = seen.get(value); + if (existing !== undefined) + return existing; + /** 数组和 plain object 使用对应容器复制。 */ + /** 与原值容器类型一致的可写中间副本。 */ + const copy: Record | unknown[] = Array.isArray(value) ? [] : {}; + seen.set(value, copy); + /** 数组允许 index/length data property,但仍拒绝 accessor 和 Symbol。 */ + const descriptors = Array.isArray(value) + ? Object.getOwnPropertyDescriptors(value) + : dataProperties(value, label); + if (Array.isArray(value)) { + if (Object.getOwnPropertySymbols(value).length > 0) + throw new TypeError(`${label} must not contain symbol properties.`); + for (const [field, descriptor] of Object.entries(descriptors)) { + if (!('value' in descriptor)) + throw new TypeError(`${label}.${field} must be a data property.`); + } + } + for (const [field, descriptor] of Object.entries(descriptors)) { + if (Array.isArray(copy) && field === 'length') + continue; + (copy as Record)[field] = snapshotValue(descriptor.value, `${label}.${field}`, seen); + } + return Object.freeze(copy); +} + +/** + * 校验顶层 option 字段并建立结构快照。 + * + * @param input 调用方提供的 option 对象。 + * @param allowed 当前精确 Rolldown 版本已审核字段。 + * @param forbidden Core 接管字段。 + * @param label 稳定诊断标签。 + * @returns 与调用方容器解除引用的副本。 + */ +function snapshotOptions( + input: unknown, + allowed: ReadonlySet, + forbidden: ReadonlySet, + label: string, +): Record { + if (input === undefined) + return {}; + /** 顶层 options 的完整 data property。 */ + const descriptors = dataProperties(input, label); + /** 先验证全部字段,不向未知未来能力默默放行。 */ + for (const field of Object.keys(descriptors)) { + if (forbidden.has(field)) + throw new TypeError(`${label}.${field} is managed by Core.`); + if (field !== 'plugins' && !allowed.has(field)) + throw new TypeError(`${label}.${field} is not supported by this managed Rolldown version.`); + } + /** 参数副本仅包含已审核 data property。 */ + const snapshot: Record = {}; + for (const [field, descriptor] of Object.entries(descriptors)) { + if (field !== 'plugins') + snapshot[field] = snapshotValue(descriptor.value, `${label}.${field}`); + } + return snapshot; +} + +/** + * 展开 Promise/Array/Falsy Rolldown Plugin option。 + * + * @param option 递归 Plugin 声明。 + * @param label 当前字段路径。 + * @param target 展平后的 Plugin 容器。 + */ +async function flattenPlugins( + option: unknown, + label: string, + target: EnginePlugin[], +): Promise { + /** PromiseLike 解析后立即复制其返回外壳。 */ + /** 当前展平节点解析 PromiseLike 后的值。 */ + const value: unknown = typeof option === 'object' && option !== null && 'then' in option + ? await Promise.resolve(option) + : option; + if (value === false || value === null || value === undefined) + return; + if (Array.isArray(value)) { + for (let index = 0; index < value.length; index += 1) + await flattenPlugins(value[index], `${label}[${index}]`, target); + return; + } + target.push(snapshotPlugin(value, label)); +} + +/** + * 复制 Plugin hook 与 filter 外壳,固定当次执行的函数引用。 + * + * @param value 函数或 object-hook。 + * @param label 当前 hook 路径。 + * @returns 与调用方 hook 容器解除引用的副本。 + */ +function snapshotHook(value: unknown, label: string, hook: string): unknown { + if (typeof value === 'function') + return wrapProtectedOptionsHook(value as (this: unknown, ...args: unknown[]) => unknown, hook, label); + if (typeof value === 'string') + return value; + /** object-hook 外壳的全部 data property。 */ + const descriptors = dataProperties(value, label); + /** object-hook 只允许 Rolldown 公开的外壳字段。 */ + const allowed = new Set(['handler', 'order', 'filter', 'sequential']); + for (const field of Object.keys(descriptors)) { + if (!allowed.has(field)) + throw new TypeError(`${label}.${field} is not a supported Plugin hook property.`); + } + if (typeof descriptors.handler?.value !== 'function' && typeof descriptors.handler?.value !== 'string') + throw new TypeError(`${label}.handler must be callable or a string addon.`); + /** handler identity 保留,filter/order 容器建立快照。 */ + const result: Record = { + handler: typeof descriptors.handler.value === 'function' + ? wrapProtectedOptionsHook(descriptors.handler.value as (this: unknown, ...args: unknown[]) => unknown, hook, label) + : descriptors.handler.value, + }; + for (const field of ['order', 'filter', 'sequential']) { + if (descriptors[field] !== undefined) + result[field] = snapshotValue(descriptors[field]!.value, `${label}.${field}`); + } + return Object.freeze(result); +} + +/** + * 捕获一组受保护 option 字段的 presence 与 identity。 + * + * @param options Rolldown 传入或 Plugin 返回的 options。 + * @param fields 受 Core 接管字段。 + * @param label 稳定诊断标签。 + * @returns 用于 hook 前后对比的快照。 + */ +function protectedFieldSnapshot( + options: unknown, + fields: ReadonlySet, + label: string, +): ReadonlyMap { + /** 受检 options 的全部 data property。 */ + const descriptors = dataProperties(options, label); + return new Map([...fields].map(field => [field, Object.freeze({ + present: descriptors[field] !== undefined, + ...(descriptors[field] === undefined ? {} : { value: descriptors[field]!.value }), + })])); +} + +/** + * 确认 Plugin hook 没有改写 Core-owned option。 + * + * @param baseline hook 执行前快照。 + * @param candidate hook 执行后原对象或返回对象。 + * @param fields 受保护字段。 + * @param label 稳定诊断标签。 + */ +function assertProtectedFields( + baseline: ReadonlyMap, + candidate: unknown, + fields: ReadonlySet, + label: string, +): void { + /** hook 执行后对象的受保护字段快照。 */ + const current = protectedFieldSnapshot(candidate, fields, label); + for (const field of fields) { + /** hook 执行前的字段 presence/identity。 */ + const before = baseline.get(field)!; + /** hook 执行后的字段 presence/identity。 */ + const after = current.get(field)!; + if (before.present !== after.present || (before.present && !Object.is(before.value, after.value))) + throw new TypeError(`${label} must not change Core-managed field "${field}".`); + } +} + +/** + * 包装 options/outputOptions hook,阻断对 Core-owned 字段的原地和返回值改写。 + * + * @param handler trusted Plugin 原始 handler。 + * @param hook 当前 hook 名。 + * @param label 稳定诊断标签。 + * @returns 保留 this/参数/返回语义的受管 handler。 + */ +function wrapProtectedOptionsHook( + handler: (this: unknown, ...args: unknown[]) => unknown, + hook: string, + label: string, +): (this: unknown, ...args: unknown[]) => unknown { + /** 只有 options/outputOptions 存在 Core-owned 字段集。 */ + const fields = hook === 'options' + ? PROTECTED_INPUT_HOOK_FIELDS + : hook === 'outputOptions' + ? PROTECTED_OUTPUT_HOOK_FIELDS + : undefined; + if (fields === undefined) + return handler; + if (hook === 'outputOptions') { + return function managedOutputOptionsHook(this: unknown, ...args: unknown[]): unknown { + /** Rolldown 传入的当前 output options。 */ + const options = args[0]; + /** outputOptions 执行前的 Core-owned 字段快照。 */ + const baseline = protectedFieldSnapshot(options, fields, `${label} input`); + /** trusted outputOptions handler 的原始返回值。 */ + const result = handler.apply(this, args); + /** outputOptions 是 Rolldown 同步 hook,thenable 是运行时契约违反。 */ + if (typeof result === 'object' && result !== null && 'then' in result) + throw new TypeError(`${label} must be synchronous.`); + assertProtectedFields(baseline, options, fields, `${label} input`); + if (result !== undefined && result !== null) + assertProtectedFields(baseline, result, fields, `${label} result`); + return result; + }; + } + return async function managedOptionsHook(this: unknown, ...args: unknown[]): Promise { + /** Rolldown 传入的当前 input options。 */ + const options = args[0]; + /** options 执行前的 Core-owned 字段快照。 */ + const baseline = protectedFieldSnapshot(options, fields, `${label} input`); + /** trusted options handler 解析后的原始返回值。 */ + const result = await handler.apply(this, args); + assertProtectedFields(baseline, options, fields, `${label} input`); + if (result !== undefined && result !== null) + assertProtectedFields(baseline, result, fields, `${label} result`); + return result; + }; +} + +/** + * 校验并复制一个 trusted managed Plugin。 + * + * @param value 展平后的 Plugin 候选。 + * @param label Plugin option 位置。 + * @returns Rolldown 可直接执行的冻结外壳。 + */ +function snapshotPlugin(value: unknown, label: string): EnginePlugin { + /** trusted Plugin 外壳的全部 data property。 */ + const descriptors = dataProperties(value, label); + for (const field of Object.keys(descriptors)) { + if (FORBIDDEN_PLUGIN_HOOKS.has(field)) + throw new TypeError(`${label}.${field} is forbidden because Core never runs write/watch lifecycles.`); + if (!PLUGIN_FIELDS.has(field) && !PLUGIN_HOOKS.has(field)) + throw new TypeError(`${label}.${field} is not supported by this managed Rolldown version.`); + } + if (typeof descriptors.name?.value !== 'string' || descriptors.name.value.length === 0) + throw new TypeError(`${label}.name must be a non-empty string.`); + /** Plugin 外壳保留 api identity,其他容器与 hook 外壳建立快照。 */ + const plugin: Record = { name: descriptors.name.value }; + for (const field of ['version', 'meta']) { + if (descriptors[field] !== undefined) + plugin[field] = snapshotValue(descriptors[field]!.value, `${label}.${field}`); + } + if (descriptors.api !== undefined) + plugin.api = descriptors.api.value; + for (const hook of PLUGIN_HOOKS) { + if (descriptors[hook] !== undefined) + plugin[hook] = snapshotHook(descriptors[hook]!.value, `${label}.${hook}`, hook); + } + return Object.freeze(plugin) as unknown as EnginePlugin; +} + +/** 快照后的 Rolldown input options 与已展平 Plugin。 */ +export interface NormalizedManagedInput { + readonly options: EngineInputOptions; + readonly plugins: readonly EnginePlugin[]; + readonly tsconfig?: false | import('../../contracts/services.js').SourceFileRef; +} + +/** 快照后的 Rolldown output options 与已展平 Plugin。 */ +export interface NormalizedManagedOutput { + readonly options: EngineOutputOptions; + readonly plugins: readonly EnginePlugin[]; +} + +/** + * 展开、校验并快照一组 Rolldown Plugin option。 + * + * @param option 输入或输出 Plugin option。 + * @param label 稳定诊断标签。 + * @returns 固定顺序的 Plugin 外壳数组。 + */ +async function snapshotPlugins( + option: unknown, + label: string, +): Promise { + /** 递归展平的中间列表不向 Rolldown 暴露。 */ + const flattened: EnginePlugin[] = []; + await flattenPlugins(option, label, flattened); + return Object.freeze(flattened); +} + +/** + * 快照 managed input options 与其 Plugin 树。 + * + * @param input 调用方 input options。 + * @returns 已审核的 Rolldown input 副本。 + */ +export async function normalizeManagedInput(input: unknown): Promise { + /** 原始 input options 的 data property,仅用于取得 Plugin option。 */ + const descriptors = input === undefined ? {} : dataProperties(input, 'inputOptions'); + /** tsconfig 是 Core-owned SourceRef 能力,不进入通用结构复制。 */ + const tsconfig = descriptors.tsconfig?.value; + if (tsconfig !== undefined && tsconfig !== false && (typeof tsconfig !== 'object' || tsconfig === null)) + throw new TypeError('inputOptions.tsconfig must be false or an authorized SourceFileRef.'); + /** 排除受管 tsconfig 后的 input option data property 容器。 */ + const snapshotInput = Object.fromEntries(Object.entries(descriptors) + .filter(([field]) => field !== 'tsconfig') + .map(([field, descriptor]) => [field, descriptor.value])); + /** 不含 plugins/tsconfig 的已审核 input option 结构快照。 */ + const snapshot = snapshotOptions(snapshotInput, INPUT_FIELDS, FORBIDDEN_INPUT_FIELDS, 'inputOptions'); + if (snapshot.experimental !== undefined) { + /** 已快照 experimental 对象的全部 data property。 */ + const experimental = dataProperties(snapshot.experimental, 'inputOptions.experimental'); + for (const field of FORBIDDEN_EXPERIMENTAL_FIELDS) { + if (experimental[field] !== undefined) + throw new TypeError(`inputOptions.experimental.${field} is forbidden by the managed lifecycle.`); + } + } + /** tsconfig 默认关闭,避免 Rolldown 隐式搜索未授权工程文件。 */ + return Object.freeze({ + options: Object.freeze({ + ...snapshot, + tsconfig: false, + }) as EngineInputOptions, + plugins: await snapshotPlugins(descriptors.plugins?.value, 'inputOptions.plugins'), + ...(tsconfig === undefined ? {} : { tsconfig: tsconfig as false | import('../../contracts/services.js').SourceFileRef }), + }); +} + +/** + * 快照一组 managed output options 与其 Plugin 树。 + * + * @param input 调用方 output options。 + * @param label 包含 output ID 的诊断标签。 + * @returns 已审核的 Rolldown output 副本。 + */ +export async function normalizeManagedOutput(input: unknown, label: string): Promise { + /** 不含 plugins 的已审核 output option 结构快照。 */ + const snapshot = snapshotOptions(input, OUTPUT_FIELDS, FORBIDDEN_OUTPUT_FIELDS, label); + /** 原始 output options 的 data property,仅用于取得 Plugin option。 */ + const descriptors = dataProperties(input, label); + return Object.freeze({ + options: Object.freeze(snapshot) as EngineOutputOptions, + plugins: await snapshotPlugins(descriptors.plugins?.value, `${label}.plugins`), + }); +} diff --git a/packages/core/src/compiler/module-host.ts b/packages/core/src/compiler/module-host.ts new file mode 100644 index 0000000..a8311b1 --- /dev/null +++ b/packages/core/src/compiler/module-host.ts @@ -0,0 +1,318 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import type { + ModuleService, + SourceFileRef, +} from '../contracts/services.js'; +import { safeRelativePath, validatePhysicalEntry } from '../security/path-policy.js'; +import { SourceRegistry } from '../services/sources.js'; +import { WatchRegistry, type WatchObservation } from '../services/watch.js'; +import { WorkDirectoryRegistry } from '../services/work-directories.js'; +import { loadManagedEngine, type EngineInputOptions, type EngineOutputOptions, type ManagedEngine } from './engine-loader.js'; +import { packageScope, type ManagedPackageScope } from './managed/boundary.js'; +import { normalizeNodeBuiltin, portableNodePolicyPlugin } from './portable-node/policy.js'; + +/** Module Host operation 使用的稳定 ID。 */ +const MODULE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** 路径语义之外的 bare/package-import specifier。 */ +function isPackageSpecifier(source: string): boolean { + return source.startsWith('#') || (!source.startsWith('.') + && !source.startsWith('/') + && !source.startsWith('file:') + && !source.startsWith('\0')); +} + +/** Core Module Host 使用的 owner-scoped resolver 状态。 */ +interface ModuleResolutionState { + readonly projectRoot: string; + readonly sourceRoot: string; + readonly packages: Map; + readonly packageEntries: Map; + readonly resolutionManifests: Set; +} + +/** + * 查找 package imports 解析所依赖的最近 package.json。 + * + * @param state 当前 Module operation 边界。 + * @param importer 发起 `#` import 的模块。 + */ +async function observeNearestManifest(state: ModuleResolutionState, importer: string | undefined): Promise { + if (importer === undefined) + return; + /** query 不参与物理祖先查找。 */ + let directory = path.dirname(importer.replace(/\?.*$/u, '')); + while (true) { + if (!path.isAbsolute(directory) || path.relative(state.projectRoot, directory).startsWith(`..${path.sep}`)) + return; + /** `#imports` 的语义由最近 package scope manifest 决定。 */ + const manifest = path.join(directory, 'package.json'); + /** 当前候选 manifest 的普通文件状态。 */ + const stat = await fs.lstat(manifest).catch(() => undefined); + if (stat?.isFile() === true && !stat.isSymbolicLink()) { + state.resolutionManifests.add(await fs.realpath(manifest)); + return; + } + if (path.resolve(directory) === path.resolve(state.projectRoot)) + return; + /** 下一层 package scope 候选目录。 */ + const parent = path.dirname(directory); + if (parent === directory) + return; + directory = parent; + } +} + +/** + * 建立本地源码闭包与外部 package identity 的 Module Host resolver。 + * + * @param state 当前 load operation 的授权 source/package 集。 + * @returns 只 externalize 已证明 package entry 的 Core Plugin。 + */ +function moduleResolutionPlugin(state: ModuleResolutionState): import('../contracts/compiler.js').ManagedRolldownPlugin { + return Object.freeze({ + name: 'acplugin-module-resolution', + resolveId: { + order: 'pre' as const, + /** 先让同一 Rolldown resolver 得到精确 exports/imports 结果,再建立边界。 */ + async handler(source, importer, options) { + /** Node builtin 始终使用唯一 node: external identity。 */ + const builtin = normalizeNodeBuiltin(source); + if (builtin !== undefined) + return { id: builtin, external: true }; + /** NUL virtual helpers 由其他 Core Plugin 处理。 */ + if (source.startsWith('\0')) + return null; + if (source.startsWith('#')) + await observeNearestManifest(state, importer); + /** skipSelf 保留 Rolldown Node-compatible exports/imports 解析。 */ + const resolved = await this.resolve(source, importer, { ...options, skipSelf: true }); + if (resolved === null) + throw new Error('Module Host could not resolve an imported module.'); + if (resolved.external) + throw new Error('Module Host received an unverified external import.'); + /** resolver 结果必须是普通物理文件。 */ + const file = resolved.id.replace(/\?.*$/u, ''); + if (!path.isAbsolute(file)) + throw new Error('Module Host resolved an unsafe non-file module.'); + /** resolver 返回模块的真实物理路径。 */ + const real = await fs.realpath(file); + /** 裸 package import 即使物理上位于 projectRoot/node_modules,也必须保持 package identity。 */ + if (isPackageSpecifier(source) && !source.startsWith('#')) { + /** dependency 保存真实 Package root、name 与 version identity。 */ + const dependency = await packageScope(real); + if (dependency === undefined) + throw new Error('Module Host package import has no valid package identity.'); + state.packages.set(dependency.root, dependency); + state.packageEntries.set(real, dependency); + return { id: pathToFileURL(real).href, external: true }; + } + /** local relative/# graph 必须留在入口被授予的 Source root。 */ + const relative = path.relative(state.sourceRoot, real); + if (relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`))) { + /** 每个实际读取的模块逐项拒绝 symlink/special/escape,不扫描无关工程目录。 */ + await validatePhysicalEntry(state.sourceRoot, file, 'file'); + return { ...resolved, id: real }; + } + /** `#imports` 只有显式解析到外部 package 时才可建立 package capability。 */ + if (!source.startsWith('#')) + throw new Error('Module Host local import escaped its authorized source root.'); + /** bare/# 解析到的最近 package identity。 */ + const dependency = await packageScope(real); + if (dependency === undefined) + throw new Error('Module Host package import has no valid package identity.'); + state.packages.set(dependency.root, dependency); + state.packageEntries.set(real, dependency); + return { id: pathToFileURL(real).href, external: true }; + }, + }, + }); +} + +/** Module Host 初始化依赖。 */ +export interface ModuleHostOptions { + readonly projectRoot: string; + readonly sources: SourceRegistry; + readonly workDirectories: WorkDirectoryRegistry; + readonly watch: WatchRegistry; +} + +/** Core 唯一、按 owner 签发可信 TS/JS Rolldown loader 的 Module Host。 */ +export class ModuleHost { + /** 工程解析根。 */ + readonly #projectRoot: string; + /** 系统祖先 symlink 解析后的工程物理根。 */ + readonly #projectRealRoot: Promise; + /** SourceRef 运行时授权注册表。 */ + readonly #sources: SourceRegistry; + /** 生成并执行临时 ESM 的 owner workDir。 */ + readonly #workDirectories: WorkDirectoryRegistry; + /** BuildSession 唯一 Watch Registry。 */ + readonly #watch: WatchRegistry; + /** 与 Compiler Host 相同的唯一 Rolldown driver。 */ + readonly #engine: Promise; + /** owner 内已消费的 module operation ID。 */ + readonly #operations = new Map>(); + + /** + * 创建 BuildSession 唯一 Module Host。 + * + * @param options 当前 Session registries。 + */ + constructor(options: ModuleHostOptions) { + this.#projectRoot = path.resolve(options.projectRoot); + this.#projectRealRoot = fs.realpath(this.#projectRoot); + this.#sources = options.sources; + this.#workDirectories = options.workDirectories; + this.#watch = options.watch; + this.#engine = loadManagedEngine(); + } + + /** + * 为 Framework/Integration owner 签发闭包绑定 ModuleService。 + * + * @param owner 当前 Kernel owner。 + * @returns 不接受调用方自报 owner/path 的 loader。 + */ + service(owner: string): ModuleService { + if (typeof owner !== 'string' || owner.length === 0) + throw new Error('Module owner must be a non-empty string.'); + return Object.freeze({ + /** request 只能包含 stable ID 和 SourceFileRef。 */ + loadDefault: (request: { readonly id: string; readonly entry: SourceFileRef }) => this.#loadDefault(owner, request), + }); + } + + /** + * Bundle、fresh evaluate 并返回一个可信模块的 default export。 + * + * @param owner 当前 service owner。 + * @param request stable ID 与入口 ref。 + * @returns 原始 default export;schema normalization 由消费者负责。 + */ + async #loadDefault( + owner: string, + request: { readonly id: string; readonly entry: SourceFileRef }, + ): Promise { + if (typeof request !== 'object' || request === null + || Object.keys(request).some(field => field !== 'id' && field !== 'entry')) { + throw new Error('Module load request must contain only id and entry.'); + } + if (!MODULE_ID.test(request.id)) + throw new Error('Module load id must use lowercase kebab-case.'); + /** operation ID 在执行前消费,防止覆盖同一 work output。 */ + const operations = this.#operations.get(owner) ?? new Set(); + if (operations.has(request.id)) + throw new Error(`Module load id "${request.id}" was already used by this owner.`); + operations.add(request.id); + this.#operations.set(owner, operations); + /** 入口指纹必须在 Rolldown 读取前复核。 */ + const record = await this.#sources.validatedFile(owner, request.entry); + /** Rolldown 入口真实物理路径。 */ + const entry = await fs.realpath(record.physicalPath); + /** local graph 不能逃逸的授权 source root。 */ + const sourceRoot = await fs.realpath(record.root); + /** 当前 operation 经 bare/# import 证明的 package 集。 */ + const packages = new Map(); + /** externalized package entry 到 package identity 的精确证明。 */ + const packageEntries = new Map(); + /** `package.json#imports` 解析所读取的最近 manifest。 */ + const resolutionManifests = new Set(); + /** 当前 operation 完整的 resolver observation state。 */ + const resolutionState: ModuleResolutionState = { + projectRoot: await this.#projectRealRoot, + sourceRoot, + packages, + packageEntries, + resolutionManifests, + }; + /** entry 最近 package scope 影响 imports/type 语义,始终进入 watch。 */ + await observeNearestManifest(resolutionState, entry); + /** 与 Compiler Host 完全相同的 Rolldown driver。 */ + const engine = await this.#engine; + /** 固定 Module Host 入口/tsconfig/log/plugin 边界。 */ + const inputOptions: EngineInputOptions = { + input: { module: entry }, + cwd: this.#projectRoot, + platform: 'node', + tsconfig: false, + logLevel: 'silent', + watch: false, + /** builtin 由 resolver 规范成 node: external,其余 import 必须显式解析。 */ + external: id => normalizeNodeBuiltin(id) !== undefined, + plugins: [ + moduleResolutionPlugin(resolutionState), + portableNodePolicyPlugin(engine), + ], + }; + /** create 成功后任何 generate/import 错误都必须 close bundle。 */ + let bundle: Awaited> | undefined; + try { + bundle = await engine.create(inputOptions); + /** Module Host 的固定单 ESM output options。 */ + const outputOptions: EngineOutputOptions = { + format: 'es', + entryFileNames: 'module.mjs', + codeSplitting: false, + sourcemap: false, + /** 消除 Rolldown 1.2.2 绝对 module region,不压缩表达式或名称。 */ + minify: { compress: false, mangle: false }, + }; + /** generate-only 的单文件内存输出。 */ + const output = await bundle.generate(outputOptions); + if (output.output.length !== 1 || output.output[0]?.type !== 'chunk' + || output.output[0].fileName !== 'module.mjs' || !output.output[0].isEntry) + throw new Error('Module Host must produce exactly one ESM entry chunk.'); + /** 输出只写当前 owner workDir,不使用 dist 或系统任意临时路径。 */ + const workDirectory = await this.#workDirectories.directory(owner); + /** 当前 load operation 的固定 workDir-relative 文件。 */ + const relative = safeRelativePath(`modules/${request.id}/module.mjs`); + /** 只在 Host 内部可见的执行物理路径。 */ + const physical = this.#workDirectories.resolve(owner, workDirectory, relative); + await fs.mkdir(path.dirname(physical), { recursive: true, mode: 0o700 }); + await fs.writeFile(physical, output.output[0].code, { flag: 'wx', mode: 0o600 }); + /** local graph、package entries/manifests 形成一个原子 watch operation。 */ + const observations = new Map(); + for (const file of await bundle.watchFiles) { + /** 当前 Rolldown module/watch input 的真实路径。 */ + const real = await fs.realpath(file); + /** 外部 watch input 所属的 package identity。 */ + const dependency = [...packages.values()].find(candidate => real === candidate.root || real.startsWith(`${candidate.root}${path.sep}`)); + observations.set(real, Object.freeze({ + path: real, + type: 'file' as const, + ...(dependency === undefined ? {} : { identity: `package:${dependency.name}@${dependency.version}/${path.relative(dependency.root, real).split(path.sep).join('/')}` }), + })); + } + for (const dependency of packages.values()) { + /** package exports/imports 身份所依赖的 manifest。 */ + const manifest = path.join(dependency.root, 'package.json'); + observations.set(manifest, Object.freeze({ + path: manifest, + type: 'file' as const, + identity: `package:${dependency.name}@${dependency.version}/package.json`, + })); + } + for (const [file, dependency] of packageEntries) { + /** external module 不一定进入 Rolldown watchFiles,必须显式观察真实 entry。 */ + observations.set(file, Object.freeze({ + path: file, + type: 'file' as const, + identity: `package:${dependency.name}@${dependency.version}/${path.relative(dependency.root, file).split(path.sep).join('/')}`, + })); + } + for (const manifest of resolutionManifests) + observations.set(manifest, Object.freeze({ path: manifest, type: 'file' as const })); + await this.#watch.replace(owner, `module/${request.id}`, [...observations.values()]); + /** query 只用于本 Session fresh evaluation;不进入报告或输出。 */ + const namespace = await import(`${pathToFileURL(physical).href}?acplugin=${encodeURIComponent(request.id)}`) as Record; + if (!Object.prototype.hasOwnProperty.call(namespace, 'default')) + throw new Error('Module Host entry must provide a default export.'); + return namespace.default as T; + } finally { + await bundle?.close(); + } + } +} diff --git a/packages/core/src/compiler/physical-path-auditor.ts b/packages/core/src/compiler/physical-path-auditor.ts new file mode 100644 index 0000000..344f039 --- /dev/null +++ b/packages/core/src/compiler/physical-path-auditor.ts @@ -0,0 +1,45 @@ +import path from 'node:path'; + +/** @returns haystack 是否包含完整 needle 字节序列。 */ +function containsBytes(haystack: Uint8Array, needle: Uint8Array): boolean { + if (needle.byteLength === 0 || needle.byteLength > haystack.byteLength) + return false; + /** 物理路径很短且输出在内存中,直接扫描避免把二进制 Asset 强制解码。 */ + outer: for (let offset = 0; offset <= haystack.byteLength - needle.byteLength; offset += 1) { + for (let index = 0; index < needle.byteLength; index += 1) { + if (haystack[offset + index] !== needle[index]) + continue outer; + } + return true; + } + return false; +} + +/** + * 拒绝输出字节中的 Core-known 物理根,且绝不在错误中回显 marker。 + * + * @param bytes 最终待签发输出字节。 + * @param roots project/source/work/package 等物理根。 + * @param message 稳定、无物理路径的失败文案。 + */ +export function assertNoPhysicalPathBytes( + bytes: Uint8Array, + roots: readonly string[], + message: string, +): void { + /** 同一 root 的宿主与 POSIX separator 形态都属于泄漏。 */ + const markers = new Set(); + for (const root of roots) { + if (root.length <= 1) + continue; + markers.add(root); + markers.add(root.split(path.sep).join('/')); + markers.add(root.replaceAll('\\', '/')); + } + /** 编码器保持物理 marker 与输出都按原始 UTF-8 字节比较。 */ + const encoder = new TextEncoder(); + for (const marker of markers) { + if (containsBytes(bytes, encoder.encode(marker))) + throw new Error(message); + } +} diff --git a/packages/core/src/compiler/portable-node/auditor.ts b/packages/core/src/compiler/portable-node/auditor.ts new file mode 100644 index 0000000..1a96fdd --- /dev/null +++ b/packages/core/src/compiler/portable-node/auditor.ts @@ -0,0 +1,102 @@ +/** portable-node 模块图审计。 */ +import type { CompileModuleReport } from '../../contracts/compiler.js'; +import { compareCodePoints } from '../../security/path-policy.js'; +import type { AuditedModule } from '../managed/auditor.js'; +import type { EngineOutput } from '../engine-loader.js'; +import { normalizeNodeBuiltin } from './policy.js'; +import { assertNoPhysicalPathBytes } from '../physical-path-auditor.js'; + +/** portable-node 一个入口的固定审计结果。 */ +export interface PortableOutput { + readonly bytes: Uint8Array; + readonly modules: readonly AuditedModule[]; +} + +/** + * 扫描最终输出是否泄露 Core 可知的物理根。 + * + * @param code 最终 ESM 字节文本。 + * @param roots project/source/work/package 等物理根。 + */ +/** + * 审计一个独立 entry 的固定 Node 20 ESM 输出闭包。 + * + * @param output Rolldown generate() 原始内存输出。 + * @param entryId 当前 stable entry ID。 + * @param modules 已授权最终模块图。 + * @param physicalRoots 不得进入产物字节的物理根。 + * @returns 唯一 main.mjs 字节。 + */ +export function auditPortableOutput( + output: EngineOutput, + entryId: string, + modules: readonly AuditedModule[], + physicalRoots: readonly string[], +): PortableOutput { + if (output.output.length !== 1 || output.output[0]?.type !== 'chunk') + throw new Error('Portable Node must produce exactly one entry chunk and no assets.'); + /** 唯一输出必须由固定 naming policy 产生。 */ + const chunk = output.output[0]; + if (chunk.fileName !== 'main.mjs' || !chunk.isEntry || chunk.name !== entryId) + throw new Error('Portable Node output does not match its fixed main.mjs entry contract.'); + if ((chunk.sourcemapFileName !== undefined && chunk.sourcemapFileName !== null) + || (chunk.map !== null && chunk.map !== undefined)) + throw new Error('Portable Node must not produce sourcemaps.'); + /** Chunk 模块必须全部出现在 Plugin 外最终授权图中。 */ + const authorized = new Set(modules.map(module => module.physicalId)); + /** Rolldown 在 treeshake:false 时注入的固定内部 helper 没有 ModuleInfo,不属于作者模块。 */ + const engineInternal = new Set(['\0rolldown/runtime.js']); + if ([...chunk.moduleIds, ...Object.keys(chunk.modules)].some(id => !authorized.has(id) && !engineInternal.has(id))) + throw new Error('Portable Node output references a module outside the audited graph.'); + if (modules.some(module => /\.node(?:[?#]|$)/u.test(module.physicalId)) || /\.node(?:[?#'"`]|$)/u.test(chunk.code)) + throw new Error('Portable Node bundles must not contain native addons.'); + /** 唯一可保留的 external 是已规范化 node: builtin。 */ + for (const imported of chunk.imports) { + if (normalizeNodeBuiltin(imported) !== imported) + throw new Error('Portable Node output contains a residual non-node import.'); + } + /** codeSplitting:false 应只留下同文件内部动态初始化,不得引用其他文件。 */ + if (chunk.dynamicImports.some(imported => imported !== 'main.mjs')) + throw new Error('Portable Node output contains a residual dynamic import.'); + /** 最终代码按实际交付的 UTF-8 字节执行物理路径审计。 */ + const bytes = new TextEncoder().encode(chunk.code); + assertNoPhysicalPathBytes(bytes, physicalRoots, 'Portable Node output contains an absolute build path.'); + return Object.freeze({ + bytes, + modules: Object.freeze([...modules]), + }); +} + +/** + * 合并多个独立 entry 的脱敏模块图,保留全部稳定边。 + * + * @param reports 每个 entry 的 module reports。 + * @returns 按逻辑 ID 排序的 Job 总图。 + */ +export function mergePortableModuleReports( + reports: readonly (readonly CompileModuleReport[])[], +): readonly CompileModuleReport[] { + /** 同一逻辑模块可能出现在多个独立 bundle 中。 */ + const merged = new Map; importedBy: Set }>(); + for (const report of reports) { + for (const module of report) { + /** 当前逻辑 ID 已累积或新建的合并节点。 */ + const current = merged.get(module.id) ?? { kind: module.kind, inputs: new Set(), importedBy: new Set() }; + if (current.kind !== module.kind) + throw new Error('Portable Node module graph contains an inconsistent logical identity.'); + for (const input of module.inputs) + current.inputs.add(input); + for (const importer of module.importedBy) + current.importedBy.add(importer); + merged.set(module.id, current); + } + } + return Object.freeze([...merged.entries()] + .sort(([left], [right]) => compareCodePoints(left, right)) + .map(([id, module]) => Object.freeze({ + id, + kind: module.kind, + inputs: Object.freeze([...module.inputs].sort(compareCodePoints)), + importedBy: Object.freeze([...module.importedBy].sort(compareCodePoints)), + }))); +} diff --git a/packages/core/src/compiler/portable-node/options.ts b/packages/core/src/compiler/portable-node/options.ts new file mode 100644 index 0000000..3fa0656 --- /dev/null +++ b/packages/core/src/compiler/portable-node/options.ts @@ -0,0 +1,108 @@ +/** portable-node 作者 options 的固定 profile 规范化。 */ +import type { PortableNodeCompileOptions } from '../../contracts/compiler.js'; +import { dataProperties } from '../job-normalizer.js'; + +/** 运行时校验并快照后的 portable-node 参数。 */ +export interface NormalizedPortableOptions { + readonly resolve?: { + readonly conditionNames?: readonly string[]; + readonly extensions?: readonly string[]; + readonly mainFields?: readonly string[]; + readonly mainFiles?: readonly string[]; + }; + readonly transform?: { + readonly define?: Readonly>; + readonly dropLabels?: readonly string[]; + readonly jsx?: false | 'react' | 'react-jsx' | 'preserve'; + }; + readonly treeshake?: boolean; +} + +/** + * 复制只包含字符串的数组。 + * + * @param value 调用方字段值。 + * @param label 稳定诊断路径。 + * @returns 与调用方断开引用的冻结数组。 + */ +function stringArray(value: unknown, label: string): readonly string[] { + if (!Array.isArray(value)) + throw new TypeError(`${label} must be an array of non-empty strings.`); + /** 数组的 index 读取后立即复制,随后验证每项。 */ + const copy = [...value]; + if (copy.some(item => typeof item !== 'string' || item.length === 0)) + throw new TypeError(`${label} must be an array of non-empty strings.`); + if (new Set(copy).size !== copy.length) + throw new TypeError(`${label} must not contain duplicates.`); + return Object.freeze(copy as string[]); +} + +/** + * 校验并复制 portable-node options 的安全 JSON 子集。 + * + * @param value 调用方 options。 + * @returns 可直接映射到固定 Rolldown preset 的冻结参数。 + */ +export function normalizePortableOptions(value: unknown): NormalizedPortableOptions { + /** portable options 省略时等价于空对象。 */ + const options = dataProperties(value, 'Portable compile options', true); + for (const field of Object.keys(options)) { + if (!new Set(['resolve', 'transform', 'treeshake']).has(field)) + throw new TypeError(`Portable compile options.${field} is unknown.`); + } + /** 可选 resolve 字段只允许四组稳定字符串列表。 */ + let resolve: NormalizedPortableOptions['resolve']; + if (options.resolve !== undefined) { + /** resolve subset 的完整 data property 集。 */ + const fields = dataProperties(options.resolve.value, 'Portable compile options.resolve'); + for (const field of Object.keys(fields)) { + if (!new Set(['conditionNames', 'extensions', 'mainFields', 'mainFiles']).has(field)) + throw new TypeError(`Portable compile options.resolve.${field} is unknown.`); + } + resolve = Object.freeze(Object.fromEntries(Object.entries(fields).map(([field, descriptor]) => [ + field, + stringArray(descriptor.value, `Portable compile options.resolve.${field}`), + ]))); + } + /** 可选 transform 字段不接受 Plugin、inject、alias 或函数。 */ + let transform: NormalizedPortableOptions['transform']; + if (options.transform !== undefined) { + /** transform subset 的完整 data property 集。 */ + const fields = dataProperties(options.transform.value, 'Portable compile options.transform'); + for (const field of Object.keys(fields)) { + if (!new Set(['define', 'dropLabels', 'jsx']).has(field)) + throw new TypeError(`Portable compile options.transform.${field} is unknown.`); + } + /** define 必须是 string-to-string plain data object。 */ + let define: Readonly> | undefined; + if (fields.define !== undefined) { + /** define 的完整 string-to-string data property 集。 */ + const definitions = dataProperties(fields.define.value, 'Portable compile options.transform.define'); + /** 逐 key 排序保证传入 Engine 的结构顺序稳定。 */ + const entries = Object.keys(definitions).sort().map((key) => { + if (key.length === 0 || typeof definitions[key]!.value !== 'string') + throw new TypeError('Portable compile options.transform.define must map non-empty keys to strings.'); + return [key, definitions[key]!.value] as const; + }); + define = Object.freeze(Object.fromEntries(entries)); + } + /** JSX 只开放 Rolldown 精确类型中的三个稳定模式和显式禁用。 */ + const jsx = fields.jsx?.value; + if (jsx !== undefined && jsx !== false && jsx !== 'react' && jsx !== 'react-jsx' && jsx !== 'preserve') + throw new TypeError('Portable compile options.transform.jsx is invalid.'); + transform = Object.freeze({ + ...(define === undefined ? {} : { define }), + ...(fields.dropLabels === undefined ? {} : { dropLabels: stringArray(fields.dropLabels.value, 'Portable compile options.transform.dropLabels') }), + ...(jsx === undefined ? {} : { jsx }), + }); + } + /** 顶层布尔/枚举参数使用 exact runtime union。 */ + const treeshake = options.treeshake?.value; + if (treeshake !== undefined && typeof treeshake !== 'boolean') + throw new TypeError('Portable compile options.treeshake must be boolean.'); + return Object.freeze({ + ...(resolve === undefined ? {} : { resolve }), + ...(transform === undefined ? {} : { transform }), + ...(treeshake === undefined ? {} : { treeshake }), + }) satisfies PortableNodeCompileOptions; +} diff --git a/packages/core/src/compiler/portable-node/policy.ts b/packages/core/src/compiler/portable-node/policy.ts new file mode 100644 index 0000000..a023941 --- /dev/null +++ b/packages/core/src/compiler/portable-node/policy.ts @@ -0,0 +1,181 @@ +/** portable-node 固定 external、解析与安全策略。 */ +import { builtinModules } from 'node:module'; +import path from 'node:path'; +import type { ManagedRolldownPlugin } from '../../contracts/compiler.js'; +import type { ManagedEngine } from '../engine-loader.js'; + +/** Node 当前 major 内全部 builtin 的非前缀规范名称。 */ +const NODE_BUILTINS = new Set(builtinModules.map(name => name.replace(/^node:/u, ''))); + +/** portable-node 明确支持的作者 TS/JS 扩展名。 */ +const PORTABLE_SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs']); + +/** + * 把 Node builtin specifier 规范成唯一 `node:` 形式。 + * + * @param source import/require specifier。 + * @returns 合法 builtin 的规范形式,否则 undefined。 + */ +export function normalizeNodeBuiltin(source: string): string | undefined { + /** node:test 等带子路径 builtin 也由 Node 列表精确决定。 */ + const name = source.replace(/^node:/u, ''); + return NODE_BUILTINS.has(name) ? `node:${name}` : undefined; +} + +/** + * 遍历 Oxc ESTree,拒绝需要运行时隐式解析的模块表达式。 + * + * @param root Rolldown 精确版本 parser 返回的 AST。 + * @param label 不含物理绝对路径的模块标签。 + */ +function auditPortableAst(root: unknown, label: string): void { + /** 使用对象 identity 防止未来 AST 引入父指针时循环。 */ + const seen = new Set(); + /** 深度优先检查所有普通 AST node/container。 */ + const visit = (value: unknown): void => { + if (typeof value !== 'object' || value === null || seen.has(value)) + return; + seen.add(value); + if (Array.isArray(value)) { + for (const item of value) + visit(item); + return; + } + /** 只读取 parser 产生的 plain node data。 */ + const node = value as Record; + if (node.type === 'ImportExpression') { + /** portable runtime 必须让 Rolldown 在构建时看见完整动态依赖。 */ + const source = node.source as Record | undefined; + if (source?.type !== 'Literal' || typeof source.value !== 'string') + throw new Error(`Portable Node module "${label}" contains a non-literal dynamic import.`); + if (/\.node(?:[?#]|$)/u.test(source.value)) + throw new Error(`Portable Node module "${label}" imports a native addon.`); + } + if (node.type === 'CallExpression') { + /** CommonJS require 同样只允许静态单字符串参数。 */ + const callee = node.callee as Record | undefined; + if (callee?.type === 'Identifier' && callee.name === 'require') { + /** require 的完整实参数组。 */ + const arguments_ = node.arguments as unknown[] | undefined; + /** 唯一允许的首个字符串 Literal 参数。 */ + const first = arguments_?.[0] as Record | undefined; + if (arguments_?.length !== 1 || first?.type !== 'Literal' || typeof first.value !== 'string') + throw new Error(`Portable Node module "${label}" contains a non-literal require.`); + if (/\.node(?:[?#]|$)/u.test(first.value)) + throw new Error(`Portable Node module "${label}" imports a native addon.`); + } + } + if (node.type === 'ImportDeclaration' || node.type === 'ExportNamedDeclaration' || node.type === 'ExportAllDeclaration') { + /** 静态 import/export 的 source 若存在必须是普通字符串。 */ + const source = node.source as Record | null | undefined; + if (source !== null && source !== undefined + && (source.type !== 'Literal' || typeof source.value !== 'string')) { + throw new Error(`Portable Node module "${label}" contains an invalid static import.`); + } + if (typeof source?.value === 'string' && /\.node(?:[?#]|$)/u.test(source.value)) + throw new Error(`Portable Node module "${label}" imports a native addon.`); + } + if (node.type === 'NewExpression') { + /** new URL(relative, import.meta.url) 会形成未纳入 Bundle/Asset graph 的隐式文件。 */ + const callee = node.callee as Record | undefined; + /** new URL 的完整实参数组。 */ + const arguments_ = node.arguments as unknown[] | undefined; + /** 候选相对运行时文件参数。 */ + const first = arguments_?.[0] as Record | undefined; + /** 候选 import.meta.url 基准参数。 */ + const second = arguments_?.[1] as Record | undefined; + /** MemberExpression 的 import.meta object。 */ + const secondObject = second?.object as Record | undefined; + if (callee?.type === 'Identifier' && callee.name === 'URL' + && first?.type === 'Literal' && typeof first.value === 'string' + && (first.value.startsWith('./') || first.value.startsWith('../')) + && second?.type === 'MemberExpression' + && secondObject?.type === 'MetaProperty') { + throw new Error(`Portable Node module "${label}" references an implicit runtime file.`); + } + } + for (const child of Object.values(node)) + visit(child); + }; + visit(root); +} + +/** + * 根据模块扩展名选择 Rolldown parser language。 + * + * @param id Rolldown module ID。 + * @returns 需要审计的语言;JSON/虚拟 runtime helper 等返回 undefined。 + */ +function portableLanguage(id: string): 'js' | 'jsx' | 'ts' | 'tsx' | undefined { + /** query 不参与物理扩展名识别。 */ + const extension = path.extname(id.replace(/\?.*$/u, '')).toLowerCase(); + if (extension === '.ts' || extension === '.mts' || extension === '.cts') + return 'ts'; + if (extension === '.tsx') + return 'tsx'; + if (extension === '.jsx') + return 'jsx'; + if (extension === '.js' || extension === '.mjs' || extension === '.cjs') + return 'js'; + /** Core virtual entries/modules没有文件扩展名,但作者格式固定为 JS/TS-ready ESM。 */ + if (id.startsWith('\0acplugin:')) + return 'ts'; + return undefined; +} + +/** + * 建立 portable-node 的 builtin 规范化与源码语法策略 Plugin。 + * + * 最终 module/output audit 仍在 Plugin 链之外执行;该 Plugin 只负责必须在 + * Rolldown 转换前观察的源语法和 builtin normalization。 + * + * @param engine Core 唯一 Rolldown driver。 + * @returns 不暴露给调用方的固定 policy Plugin。 + */ +export function portableNodePolicyPlugin(engine: ManagedEngine): ManagedRolldownPlugin { + return Object.freeze({ + name: 'acplugin-portable-node-policy', + /** bare 与 node: builtin 都规范为唯一 external identity。 */ + resolveId: { + order: 'pre' as const, + /** 规范 builtin 并在 resolver 前拒绝原生扩展。 */ + handler(source) { + if (/\.node(?:[?#]|$)/u.test(source)) + throw new Error('Portable Node bundles must not contain native addons.'); + /** 当前 specifier 的可选规范 builtin identity。 */ + const builtin = normalizeNodeBuiltin(source); + return builtin === undefined ? null : { id: builtin, external: true }; + }, + }, + /** 在 Rolldown TS transform 前拒绝无法完整打包的动态语义。 */ + transform: { + order: 'pre' as const, + /** 使用同一 Rolldown parser 审计转换前源语法。 */ + handler(code, id) { + if (/\.node(?:[?#]|$)/u.test(id)) + throw new Error('Portable Node bundles must not contain native addons.'); + /** 当前模块可审计的 JS/TS parser language。 */ + const language = portableLanguage(id); + if (language !== undefined) + auditPortableAst(engine.parse(code, id, language), id.startsWith('\0') ? 'virtual' : path.basename(id)); + return null; + }, + }, + }); +} + +/** + * 验证 portable 作者入口使用受支持的源码扩展名。 + * + * @param inputId 已解析的 source/virtual input ID。 + */ +export function assertPortableEntryExtension(inputId: string): void { + if (inputId.startsWith('\0acplugin:')) + return; + /** declaration file 即使以 .ts 结尾也不是可执行入口。 */ + const lower = inputId.toLowerCase(); + if (lower.endsWith('.d.ts') || lower.endsWith('.d.mts') || lower.endsWith('.d.cts') + || !PORTABLE_SOURCE_EXTENSIONS.has(path.extname(lower))) { + throw new Error('Portable Node entries must use a supported executable TypeScript or JavaScript extension.'); + } +} diff --git a/packages/core/src/config/resolver.ts b/packages/core/src/config/resolver.ts new file mode 100644 index 0000000..95a8f1d --- /dev/null +++ b/packages/core/src/config/resolver.ts @@ -0,0 +1,559 @@ +import path from 'node:path'; +import semver from 'semver'; +import parseSpdxExpression from 'spdx-expression-parse'; +import type { + AcpluginExtension, + AcpluginPlatform, +} from '../contracts/integrations.js'; +import type { + BuildMode, + ConfigCommand, + PluginAuthor, + PluginMetadata, + PublicCopyRule, +} from '../contracts/config.js'; +import type { PortableNodeCompileOptions } from '../contracts/compiler.js'; +import { isAcpluginExtension, isAcpluginPlatform } from '../api/definitions.js'; +import { normalizePortableOptions } from '../compiler/portable-node/options.js'; +import { DiagnosticRegistry } from '../services/diagnostics.js'; +import { isInsidePath, safeRelativePath } from '../security/path-policy.js'; + +/** Kernel 使用的绝对路径 Public copy rule。 */ +export interface ResolvedPublicCopyRule extends PublicCopyRule { + readonly source: string; +} + +/** Kernel 使用的完整 Public 配置。 */ +export interface ResolvedPublicConfig { + readonly enabled: boolean; + readonly directory: string; + readonly copy?: readonly ResolvedPublicCopyRule[]; +} + +/** Kernel 使用的内建 Runtime 配置。 */ +export interface ResolvedRuntimeConfig { + readonly enabled: boolean; + readonly directory: string; + readonly target: 'node20'; + readonly entries?: Readonly>>; + readonly compile?: PortableNodeCompileOptions; +} + +/** 带最终 strictness 的选中 Platform。 */ +export interface ResolvedPlatform { + readonly definition: AcpluginPlatform; + readonly strict: boolean; +} + +/** 不向 SDK 暴露物理路径的 Kernel 私有最终配置。 */ +export interface ResolvedKernelConfig { + readonly projectRoot: string; + readonly configFile: string; + readonly command: ConfigCommand; + readonly mode: BuildMode; + readonly metadata: Readonly; + readonly srcDirectory: string; + readonly public: ResolvedPublicConfig; + readonly runtime: ResolvedRuntimeConfig; + readonly platforms: readonly ResolvedPlatform[]; + readonly extensions: readonly AcpluginExtension[]; + readonly outDirectory: string; + readonly strict: boolean; +} + +/** 配置各层使用的 plain data property 描述符。 */ +type Descriptors = Record; + +/** 顶层配置唯一字段集合。 */ +const USER_CONFIG_FIELDS = new Set([ + 'name', 'version', 'description', 'displayName', 'author', 'homepage', 'repository', + 'license', 'keywords', 'srcDir', 'public', 'runtime', 'platforms', 'extensions', 'build', +]); + +/** 作者邮件地址的保守结构约束。 */ +const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/u; + +/** Plugin name 和 Runtime ID 共用 lowercase-kebab 规则。 */ +const STABLE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + +/** + * 读取不带 accessor/Symbol/原型行为的对象字段。 + * + * @param value 未受信任的配置值。 + * @param label 稳定诊断标签。 + * @param diagnostics 当前配置诊断集合。 + * @param fieldPath 配置字段路径。 + * @returns 合法对象的 data descriptors。 + */ +function descriptors( + value: unknown, + label: string, + diagnostics: DiagnosticRegistry, + fieldPath: readonly (string | number)[], +): Descriptors | undefined { + if (typeof value !== 'object' || value === null || Array.isArray(value) + || (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)) { + diagnostics.report('config', { + code: 'CONFIG_OBJECT_INVALID', severity: 'error', message: `${label} must be a plain object.`, fieldPath, + }); + return undefined; + } + if (Object.getOwnPropertySymbols(value).length > 0) { + diagnostics.report('config', { + code: 'CONFIG_SYMBOL_INVALID', severity: 'error', message: `${label} must not contain Symbol fields.`, fieldPath, + }); + return undefined; + } + /** descriptor 读取不会执行 getter。 */ + const result = Object.getOwnPropertyDescriptors(value); + for (const [field, descriptor] of Object.entries(result)) { + if (!('value' in descriptor)) { + diagnostics.report('config', { + code: 'CONFIG_ACCESSOR_INVALID', severity: 'error', message: `${label}.${field} must be a data property.`, fieldPath: [...fieldPath, field], + }); + return undefined; + } + } + return result; +} + +/** + * 报告未知对象字段。 + * + * @param values 当前对象 descriptors。 + * @param allowed 白名单。 + * @param diagnostics 当前诊断集合。 + * @param fieldPath 当前字段路径。 + */ +function unknownFields(values: Descriptors, allowed: ReadonlySet, diagnostics: DiagnosticRegistry, fieldPath: readonly (string | number)[]): void { + for (const field of Object.keys(values).sort()) { + if (!allowed.has(field)) { + diagnostics.report('config', { + code: 'CONFIG_FIELD_UNKNOWN', severity: 'error', message: `Unknown configuration field "${[...fieldPath, field].join('.')}".`, fieldPath: [...fieldPath, field], + }); + } + } +} + +/** + * 解析 project-relative POSIX 路径。 + * + * @param root 可信工程根。 + * @param value 配置路径值。 + * @param fallback 缺省相对路径。 + * @param label 诊断标签。 + * @param diagnostics 当前诊断集合。 + * @param allowDot 是否允许 `.` 表示工程根。 + * @returns 仍位于工程内的绝对路径。 + */ +function projectPath( + root: string, + value: unknown, + fallback: string, + label: string, + diagnostics: DiagnosticRegistry, + allowDot = false, +): string { + /** 无效输入仍返回安全 fallback,以便一次汇总更多独立配置问题。 */ + let relative = fallback; + if (value !== undefined) { + if (typeof value !== 'string' || (value === '.' ? !allowDot : value.length === 0)) { + diagnostics.report('config', { code: 'CONFIG_PATH_INVALID', severity: 'error', message: `${label} must be a project-relative POSIX path.`, fieldPath: label.split('.') }); + } else { + try { + relative = value === '.' && allowDot ? '' : safeRelativePath(value); + } catch { + diagnostics.report('config', { code: 'CONFIG_PATH_INVALID', severity: 'error', message: `${label} must be a project-relative POSIX path.`, fieldPath: label.split('.') }); + } + } + } + /** 安全 POSIX segments 按宿主路径拼接。 */ + const resolved = relative === '' ? root : path.join(root, ...relative.split('/')); + if (!isInsidePath(root, resolved)) + throw new Error('Resolved configuration path escaped the project root.'); + return resolved; +} + +/** + * 判断两个物理目录/文件边界是否互相包含。 + * + * @param left 左侧绝对路径。 + * @param right 右侧绝对路径。 + * @returns 任一方向包含时为 true。 + */ +function overlaps(left: string, right: string): boolean { + return isInsidePath(left, right) || isInsidePath(right, left); +} + +/** + * 解析并冻结作者信息。 + * + * @param value author 配置值。 + * @param diagnostics 当前诊断集合。 + * @returns 合法作者或 undefined。 + */ +function author(value: unknown, diagnostics: DiagnosticRegistry): PluginAuthor | undefined { + if (value === undefined) + return undefined; + /** author 必须先转为不会触发 getter 的字段描述符。 */ + const values = descriptors(value, 'author', diagnostics, ['author']); + if (values === undefined) + return undefined; + unknownFields(values, new Set(['name', 'email', 'url']), diagnostics, ['author']); + /** 当前 author 字段 data values。 */ + const name = values.name?.value; + /** email 保留原始输入供独立格式校验。 */ + const email = values.email?.value; + /** url 只允许可公开报告的 HTTP(S) 地址。 */ + const url = values.url?.value; + if (typeof name !== 'string' || name.trim() === '') + diagnostics.report('config', { code: 'CONFIG_AUTHOR_NAME_INVALID', severity: 'error', message: 'author.name must be a non-empty string.', fieldPath: ['author', 'name'] }); + if (email !== undefined && (typeof email !== 'string' || !EMAIL.test(email))) + diagnostics.report('config', { code: 'CONFIG_AUTHOR_EMAIL_INVALID', severity: 'error', message: 'author.email must be a valid email address.', fieldPath: ['author', 'email'] }); + if (url !== undefined && (typeof url !== 'string' || !isHttpUrl(url))) + diagnostics.report('config', { code: 'CONFIG_AUTHOR_URL_INVALID', severity: 'error', message: 'author.url must be an absolute HTTP URL.', fieldPath: ['author', 'url'] }); + if (typeof name !== 'string' || name.trim() === '') + return undefined; + return Object.freeze({ + name: name.trim(), + ...(typeof email === 'string' && EMAIL.test(email) ? { email } : {}), + ...(typeof url === 'string' && isHttpUrl(url) ? { url } : {}), + }); +} + +/** @returns 只接受 HTTP/HTTPS 的 URL 是否有效。 */ +function isHttpUrl(value: string): boolean { + try { + return new Set(['http:', 'https:']).has(new URL(value).protocol); + } catch { + return false; + } +} + +/** + * 复制 keyword 数组。 + * + * @param value 配置值。 + * @param diagnostics 当前诊断集合。 + * @returns 稳定、唯一 keyword 集。 + */ +function keywords(value: unknown, diagnostics: DiagnosticRegistry): readonly string[] { + if (value === undefined) + return Object.freeze([]); + if (!Array.isArray(value)) { + diagnostics.report('config', { code: 'CONFIG_KEYWORDS_INVALID', severity: 'error', message: 'keywords must be an array.', fieldPath: ['keywords'] }); + return Object.freeze([]); + } + /** 调用方数组在任何异步边界前复制。 */ + const input = [...value]; + /** 规范化后的唯一 keyword。 */ + const result: string[] = []; + for (const [index, item] of input.entries()) { + if (typeof item !== 'string' || item.trim() === '') { + diagnostics.report('config', { code: 'CONFIG_KEYWORD_INVALID', severity: 'error', message: 'Every keyword must be a non-empty string.', fieldPath: ['keywords', index] }); + continue; + } + /** keyword 比较使用去除两端空白后的规范文本。 */ + const normalized = item.trim(); + if (result.includes(normalized)) { + diagnostics.report('config', { code: 'CONFIG_KEYWORD_DUPLICATE', severity: 'error', message: `Keyword "${normalized}" is duplicated.`, fieldPath: ['keywords', index] }); + continue; + } + result.push(normalized); + } + return Object.freeze(result); +} + +/** + * 解析 Public 精确映射。 + * + * @param root 工程根。 + * @param value public 配置。 + * @param protectedPaths 不得被 Public 来源覆盖的路径。 + * @param diagnostics 当前诊断集合。 + * @returns 统一 Public 配置。 + */ +function publicConfig( + root: string, + value: unknown, + protectedPaths: readonly string[], + diagnostics: DiagnosticRegistry, +): ResolvedPublicConfig { + if (value === false) + return Object.freeze({ enabled: false, directory: path.join(root, 'public') }); + if (typeof value === 'string') { + /** 字符串简写表示完整复制一个工程内目录。 */ + const directory = projectPath(root, value, 'public', 'public', diagnostics, true); + if (protectedPaths.some(protectedPath => overlaps(directory, protectedPath))) + diagnostics.report('config', { code: 'CONFIG_PUBLIC_OVERLAP', severity: 'error', message: 'Public full-tree source overlaps a protected project path.', fieldPath: ['public'] }); + return Object.freeze({ enabled: true, directory }); + } + /** 对象写法允许完整目录或精确 copy rules。 */ + const values = value === undefined ? {} : descriptors(value, 'public', diagnostics, ['public']); + if (values === undefined) + return Object.freeze({ enabled: true, directory: path.join(root, 'public') }); + unknownFields(values, new Set(['dir', 'copy']), diagnostics, ['public']); + /** 每条 copy rule 都以最终 Public directory 为解析边界。 */ + const directory = projectPath(root, values.dir?.value, 'public', 'public.dir', diagnostics, true); + if (values.copy === undefined) { + if (protectedPaths.some(protectedPath => overlaps(directory, protectedPath))) + diagnostics.report('config', { code: 'CONFIG_PUBLIC_OVERLAP', severity: 'error', message: 'Public full-tree source overlaps a protected project path.', fieldPath: ['public'] }); + return Object.freeze({ enabled: true, directory }); + } + if (!Array.isArray(values.copy.value)) { + diagnostics.report('config', { code: 'CONFIG_PUBLIC_COPY_INVALID', severity: 'error', message: 'public.copy must be an array.', fieldPath: ['public', 'copy'] }); + return Object.freeze({ enabled: true, directory, copy: Object.freeze([]) }); + } + /** 每条规则按精确 resolved source 独立验证 overlap。 */ + const rules: ResolvedPublicCopyRule[] = []; + for (const [index, raw] of [...values.copy.value].entries()) { + /** 单条规则继续使用 descriptor 边界避免 accessor 执行。 */ + const rule = descriptors(raw, 'Public copy rule', diagnostics, ['public', 'copy', index]); + if (rule === undefined) + continue; + unknownFields(rule, new Set(['from', 'to']), diagnostics, ['public', 'copy', index]); + try { + /** 来源路径必须是未折叠的安全 POSIX 相对路径。 */ + const from = safeRelativePath(rule.from?.value); + /** 目标路径使用相同语法边界以保持跨平台一致。 */ + const to = safeRelativePath(rule.to?.value); + /** resolved source 只留在 Kernel 私有配置中。 */ + const source = path.join(directory, ...from.split('/')); + if (!isInsidePath(directory, source)) + throw new TypeError('escape'); + if (protectedPaths.some(protectedPath => overlaps(source, protectedPath))) { + diagnostics.report('config', { code: 'CONFIG_PUBLIC_OVERLAP', severity: 'error', message: 'Public copy source overlaps a protected project path.', fieldPath: ['public', 'copy', index, 'from'] }); + } + rules.push(Object.freeze({ from, to, source })); + } catch { + diagnostics.report('config', { code: 'CONFIG_PUBLIC_RULE_INVALID', severity: 'error', message: 'Public copy paths must be non-empty project-relative POSIX paths.', fieldPath: ['public', 'copy', index] }); + } + } + return Object.freeze({ enabled: true, directory, copy: Object.freeze(rules) }); +} + +/** + * 解析 Runtime 声明和 portable 参数。 + * + * @param srcDirectory 最终 srcDir。 + * @param value runtime 配置。 + * @param diagnostics 当前诊断集合。 + * @returns 固定 Runtime 配置。 + */ +function runtimeConfig(srcDirectory: string, value: unknown, diagnostics: DiagnosticRegistry): ResolvedRuntimeConfig { + /** Runtime 作者格式固定占用 srcDir/runtime。 */ + const directory = path.join(srcDirectory, 'runtime'); + if (value === false) + return Object.freeze({ enabled: false, directory, target: 'node20' }); + /** 省略配置等价于启用约定式自动入口。 */ + const values = value === undefined ? {} : descriptors(value, 'runtime', diagnostics, ['runtime']); + if (values === undefined) + return Object.freeze({ enabled: true, directory, target: 'node20' }); + unknownFields(values, new Set(['target', 'entries', 'compile']), diagnostics, ['runtime']); + if (values.target !== undefined && values.target.value !== 'node20') + diagnostics.report('config', { code: 'RUNTIME_TARGET_INVALID', severity: 'error', message: 'runtime.target must be node20.', fieldPath: ['runtime', 'target'] }); + /** entries 字段存在时完整替换自动发现,包括显式空对象。 */ + let entries: Record> | undefined; + if (values.entries !== undefined) { + entries = {}; + /** 显式 entries 对象完整替换自动发现集合。 */ + const inputs = descriptors(values.entries.value, 'runtime.entries', diagnostics, ['runtime', 'entries']); + for (const id of Object.keys(inputs ?? {}).sort()) { + if (!STABLE_ID.test(id)) + diagnostics.report('config', { code: 'RUNTIME_ENTRY_ID_INVALID', severity: 'error', message: `Runtime entry ID "${id}" must use lowercase kebab-case.`, fieldPath: ['runtime', 'entries', id] }); + /** 单个入口必须是只含 entry/kind 的 plain data。 */ + const entry = descriptors(inputs![id]!.value, `runtime.entries.${id}`, diagnostics, ['runtime', 'entries', id]); + if (entry === undefined) + continue; + unknownFields(entry, new Set(['entry', 'kind']), diagnostics, ['runtime', 'entries', id]); + try { + /** 入口只能引用 Runtime root 内的相对源码。 */ + const source = safeRelativePath(entry.entry?.value); + /** 省略 kind 时使用可直接执行的默认交付语义。 */ + const kind = entry.kind?.value ?? 'executable'; + if (kind !== 'executable' && kind !== 'module') + throw new TypeError('kind'); + entries[id] = Object.freeze({ entry: source, kind }); + } catch { + diagnostics.report('config', { code: 'RUNTIME_ENTRY_INVALID', severity: 'error', message: `Runtime entry "${id}" must declare a relative source and executable or module kind.`, fieldPath: ['runtime', 'entries', id] }); + } + } + } + /** portable 参数只使用 Compiler Host 的唯一 runtime normalizer。 */ + let compile: PortableNodeCompileOptions | undefined; + if (values.compile !== undefined) { + try { + compile = normalizePortableOptions(values.compile.value); + } catch { + diagnostics.report('config', { code: 'RUNTIME_COMPILE_INVALID', severity: 'error', message: 'runtime.compile contains unsupported portable-node options.', fieldPath: ['runtime', 'compile'] }); + } + } + return Object.freeze({ + enabled: true, + directory, + target: 'node20', + ...(entries === undefined ? {} : { entries: Object.freeze(entries) }), + ...(compile === undefined ? {} : { compile }), + }); +} + +/** + * 将作者配置解析为 Kernel 私有不可变配置。 + * + * @param value Module Host 返回的未知 default export。 + * @param options 固定工程身份和执行环境。 + * @returns 配置成功时的 snapshot 及全部稳定诊断。 + */ +export function resolveKernelConfig( + value: unknown, + options: { + readonly projectRoot: string; + readonly configFile: string; + readonly command: ConfigCommand; + readonly mode: BuildMode; + }, +): { readonly config?: ResolvedKernelConfig; readonly diagnostics: readonly import('../contracts/reports.js').Diagnostic[] } { + /** 所有配置错误集中到同一稳定 Registry 后一次返回。 */ + const diagnostics = new DiagnosticRegistry(); + /** 工程根由 Project 层固定,不能退化为 config 所在目录。 */ + const projectRoot = path.resolve(options.projectRoot); + /** 配置文件必须已由 Project/Source policy 确认为工程内文件。 */ + const configFile = path.resolve(options.configFile); + if (!isInsidePath(projectRoot, configFile)) + diagnostics.report('config', { code: 'CONFIG_FILE_OUTSIDE_PROJECT', severity: 'error', message: 'Configuration file must be inside the project root.' }); + /** 顶层输入也必须先证明为无行为 plain data。 */ + const values = descriptors(value, 'Configuration', diagnostics, []); + if (values === undefined) + return { diagnostics: diagnostics.diagnostics }; + unknownFields(values, USER_CONFIG_FIELDS, diagnostics, []); + + /** 三个必填 metadata 字段。 */ + const name = values.name?.value; + /** version 保留原始值交给完整 SemVer 校验。 */ + const version = values.version?.value; + /** description 最终会去除两端空白并冻结。 */ + const description = values.description?.value; + if (typeof name !== 'string' || !STABLE_ID.test(name)) + diagnostics.report('config', { code: 'CONFIG_NAME_INVALID', severity: 'error', message: 'name must use lowercase kebab-case.', fieldPath: ['name'] }); + if (typeof version !== 'string' || semver.valid(version) === null) + diagnostics.report('config', { code: 'CONFIG_VERSION_INVALID', severity: 'error', message: 'version must be a complete SemVer.', fieldPath: ['version'] }); + if (typeof description !== 'string' || description.trim() === '') + diagnostics.report('config', { code: 'CONFIG_DESCRIPTION_REQUIRED', severity: 'error', message: 'description must be a non-empty string.', fieldPath: ['description'] }); + /** displayName 是可选的人类可读展示名。 */ + const displayName = values.displayName?.value; + if (displayName !== undefined && (typeof displayName !== 'string' || displayName.trim() === '')) + diagnostics.report('config', { code: 'CONFIG_DISPLAY_NAME_INVALID', severity: 'error', message: 'displayName must be a non-empty string.', fieldPath: ['displayName'] }); + for (const field of ['homepage', 'repository'] as const) { + /** 两个公开链接复用完全相同的 HTTP(S) 边界。 */ + const candidate = values[field]?.value; + if (candidate !== undefined && (typeof candidate !== 'string' || !isHttpUrl(candidate))) + diagnostics.report('config', { code: `CONFIG_${field.toUpperCase()}_INVALID`, severity: 'error', message: `${field} must be an absolute HTTP URL.`, fieldPath: [field] }); + } + /** license 保留 SPDX 表达式而不是猜测或改写许可证。 */ + const license = values.license?.value; + if (license !== undefined) { + try { + if (typeof license !== 'string' || license.length === 0) + throw new TypeError('invalid'); + parseSpdxExpression(license); + } catch { + diagnostics.report('config', { code: 'CONFIG_LICENSE_INVALID', severity: 'error', message: 'license must be a valid SPDX expression.', fieldPath: ['license'] }); + } + } + + /** build 先解析,以便 Public overlap 使用最终 outDir。 */ + const build = values.build === undefined ? {} : descriptors(values.build.value, 'build', diagnostics, ['build']) ?? {}; + unknownFields(build, new Set(['outDir', 'strict']), diagnostics, ['build']); + if (build.strict !== undefined && typeof build.strict.value !== 'boolean') + diagnostics.report('config', { code: 'CONFIG_STRICT_INVALID', severity: 'error', message: 'build.strict must be boolean.', fieldPath: ['build', 'strict'] }); + /** strict 默认开启,Platform 可在定义层显式覆盖。 */ + const strict = typeof build.strict?.value === 'boolean' ? build.strict.value : true; + /** 作者源码目录始终解析为工程内绝对 Kernel 路径。 */ + const srcDirectory = projectPath(projectRoot, values.srcDir?.value, 'src', 'srcDir', diagnostics); + /** 输出目录由事务层完整托管且不得与源码重叠。 */ + const outDirectory = projectPath(projectRoot, build.outDir?.value, 'dist', 'build.outDir', diagnostics); + if (outDirectory === projectRoot || overlaps(srcDirectory, outDirectory)) + diagnostics.report('config', { code: 'CONFIG_DIRECTORY_OVERLAP', severity: 'error', message: 'srcDir and build.outDir must be separate project subtrees.' }); + /** Public 需要同时避开源码、输出和配置入口。 */ + const resolvedPublic = publicConfig(projectRoot, values.public?.value, [srcDirectory, outDirectory, configFile], diagnostics); + /** Runtime 始终以最终 srcDirectory 为约定根。 */ + const resolvedRuntime = runtimeConfig(srcDirectory, values.runtime?.value, diagnostics); + + /** Platform definitions 必须显式、非空、品牌有效且 ID 唯一。 */ + const platforms: ResolvedPlatform[] = []; + if (!Array.isArray(values.platforms?.value) || values.platforms.value.length === 0) { + diagnostics.report('config', { code: 'CONFIG_PLATFORMS_REQUIRED', severity: 'error', message: 'platforms must contain at least one Platform.', fieldPath: ['platforms'] }); + } else { + /** Platform ID 集合用于拒绝重复目标。 */ + const seen = new Set(); + for (const [index, candidate] of [...values.platforms.value].entries()) { + if (!isAcpluginPlatform(candidate)) { + diagnostics.report('config', { code: 'CONFIG_PLATFORM_INVALID', severity: 'error', message: 'Every platform must be created by definePlatform().', fieldPath: ['platforms', index] }); + continue; + } + if (seen.has(candidate.id)) { + diagnostics.report('config', { code: 'CONFIG_PLATFORM_DUPLICATE', severity: 'error', message: `Platform "${candidate.id}" is configured more than once.`, fieldPath: ['platforms', index] }); + continue; + } + seen.add(candidate.id); + platforms.push(Object.freeze({ definition: candidate, strict: candidate.strict ?? strict })); + } + } + /** Extension 定义同样只接受品牌实例并按 ID 去重。 */ + const extensions: AcpluginExtension[] = []; + if (values.extensions !== undefined) { + if (!Array.isArray(values.extensions.value)) { + diagnostics.report('config', { code: 'CONFIG_EXTENSIONS_INVALID', severity: 'error', message: 'extensions must be an array.', fieldPath: ['extensions'] }); + } else { + /** Extension ID 集合用于稳定拒绝重复能力。 */ + const seen = new Set(); + for (const [index, candidate] of [...values.extensions.value].entries()) { + if (!isAcpluginExtension(candidate)) { + diagnostics.report('config', { code: 'CONFIG_EXTENSION_INVALID', severity: 'error', message: 'Every extension must be created by defineExtension().', fieldPath: ['extensions', index] }); + continue; + } + if (seen.has(candidate.id)) { + diagnostics.report('config', { code: 'CONFIG_EXTENSION_DUPLICATE', severity: 'error', message: `Extension "${candidate.id}" is configured more than once.`, fieldPath: ['extensions', index] }); + continue; + } + seen.add(candidate.id); + extensions.push(candidate); + } + } + } + /** 可选 metadata 也必须在其他字段失败时独立收集诊断。 */ + const resolvedAuthor = author(values.author?.value, diagnostics); + /** keyword 解析与作者输入容器断开并完成稳定去重。 */ + const resolvedKeywords = keywords(values.keywords?.value, diagnostics); + if (diagnostics.hasErrors) + return { diagnostics: diagnostics.diagnostics }; + + /** metadata 必需字段已证明有效;完整 snapshot 递归冻结。 */ + const metadata = Object.freeze({ + name: name as string, + version: version as string, + description: (description as string).trim(), + ...(typeof displayName === 'string' ? { displayName: displayName.trim() } : {}), + ...(resolvedAuthor === undefined ? {} : { author: resolvedAuthor }), + ...(typeof values.homepage?.value === 'string' ? { homepage: values.homepage.value } : {}), + ...(typeof values.repository?.value === 'string' ? { repository: values.repository.value } : {}), + ...(typeof license === 'string' ? { license } : {}), + keywords: resolvedKeywords, + }) satisfies Readonly; + /** 最终 config 只在所有独立诊断均通过后物化。 */ + const config: ResolvedKernelConfig = Object.freeze({ + projectRoot, + configFile, + command: options.command, + mode: options.mode, + metadata, + srcDirectory, + public: resolvedPublic, + runtime: resolvedRuntime, + platforms: Object.freeze(platforms), + extensions: Object.freeze(extensions), + outDirectory, + strict, + }); + return { config, diagnostics: diagnostics.diagnostics }; +} diff --git a/packages/core/src/contracts/common.ts b/packages/core/src/contracts/common.ts new file mode 100644 index 0000000..13f48e6 --- /dev/null +++ b/packages/core/src/contracts/common.ts @@ -0,0 +1,19 @@ +/** Platform 与 Extension 共同使用且在本轮重写中保持不变的生命周期 API 版本。 */ +export const LIFECYCLE_API_VERSION = '1' as const; + +/** 同步值或 PromiseLike 值。 */ +export type Awaitable = T | PromiseLike; + +/** JSON 标量。 */ +export type JsonPrimitive = string | number | boolean | null; + +/** 可由 Core 复制、验证并冻结的 JSON 对象。 */ +export interface JsonObject { + readonly [key: string]: JsonValue; +} + +/** 可由 Core 确定性处理的 JSON 值。 */ +export type JsonValue = JsonPrimitive | readonly JsonValue[] | JsonObject; + +/** Document 中不可歧义的非空字段路径。 */ +export type DocumentFieldPath = readonly [string, ...string[]]; diff --git a/packages/core/src/contracts/compiler.ts b/packages/core/src/contracts/compiler.ts new file mode 100644 index 0000000..37a5fd4 --- /dev/null +++ b/packages/core/src/contracts/compiler.ts @@ -0,0 +1,158 @@ +import type { + InputOptions, + OutputOptions, + Plugin, +} from 'rolldown'; +import type { + AssetMode, + GeneratedAssetRef, + SourceDirectoryRef, + SourceFileRef, +} from './services.js'; + +/** portable-node 允许作者调整的只读字段。 */ +type PortableReadonlyField = T extends readonly (infer E)[] ? readonly E[] : T; + +/** 从精确 Engine 类型派生只读 JSON 参数子集。 */ +type PortableOptionSubset = Readonly<{ + [P in K]?: PortableReadonlyField>; +}>; + +/** portable-node 允许作者调整的解析参数。 */ +export type PortableNodeResolveOptions = PortableOptionSubset< + NonNullable, + 'conditionNames' | 'extensions' | 'mainFields' | 'mainFiles' +>; + +/** portable-node 允许作者调整的转换参数。 */ +export type PortableNodeTransformOptions = PortableOptionSubset< + NonNullable, + 'define' | 'dropLabels' +> & { + readonly jsx?: false | 'react' | 'react-jsx' | 'preserve'; +}; + +/** 固定 Node 20 ESM contract 内可复用的纯 JSON 编译参数。 */ +export interface PortableNodeCompileOptions { + readonly resolve?: PortableNodeResolveOptions; + readonly transform?: PortableNodeTransformOptions; + readonly treeshake?: Extract; +} + +/** Compiler Job 的来源或虚拟入口。 */ +export type CompileEntry = { + readonly type: 'source'; + readonly source: SourceFileRef; + readonly mode?: AssetMode; +} | { + readonly type: 'virtual'; + readonly code: string; + readonly resolveFrom: SourceDirectoryRef; + readonly mode?: AssetMode; +}; + +/** Core 支持的两个编译 Profile。 */ +export type CompileProfile = 'portable-node' | 'managed-rolldown'; + +/** managed Profile 禁止接受但不执行的写入和 Watch Plugin Hook。 */ +export type ForbiddenManagedPluginHook = 'writeBundle' | 'watchChange' | 'closeWatcher'; + +/** managed Profile 可调用的 Rolldown Plugin。 */ +export type ManagedRolldownPlugin = Omit; + +/** Rolldown 风格的递归 Plugin option。 */ +export type ManagedRolldownPluginOption = ManagedRolldownPlugin + | { readonly name: string } + | false + | null + | undefined + | PromiseLike + | readonly ManagedRolldownPluginOption[]; + +/** Core 从 managed input options 中接管的字段。 */ +type CoreOwnedManagedInputOption = 'input' | 'cwd' | 'plugins' | 'logLevel' | 'onwarn' | 'watch' | 'devtools' | 'output' | 'tsconfig'; + +/** trusted integration 可使用的 Rolldown input 能力。 */ +export type ManagedRolldownInputOptions = Omit & { + readonly plugins?: ManagedRolldownPluginOption; + readonly tsconfig?: false | SourceFileRef; +}; + +/** trusted integration 可使用的 Rolldown output 能力。 */ +export type ManagedRolldownOutputOptions = Omit & { + readonly plugins?: ManagedRolldownPluginOption; +}; + +/** managed Profile 的输出与审计策略。 */ +export interface ManagedRolldownCompileOptions { + readonly inputOptions?: ManagedRolldownInputOptions; + readonly outputs: readonly { readonly id: string; readonly options: ManagedRolldownOutputOptions }[]; + readonly policy?: { + readonly deterministic?: boolean; + readonly licenses?: 'strict' | 'ignore'; + readonly nativeAddons?: 'reject' | 'allow'; + readonly unresolvedImports?: 'reject' | 'allow'; + }; +} + +/** 编译 Profile 与其参数的唯一映射。 */ +export interface CompileOptionsMap { + readonly 'portable-node': PortableNodeCompileOptions; + readonly 'managed-rolldown': ManagedRolldownCompileOptions; +} + +/** 指定 Profile 的编译参数。 */ +export type CompileOptions

= CompileOptionsMap[P]; + +/** 与当前所有者能力绑定的 Compiler Job。 */ +export interface CompileJob

{ + readonly id: string; + readonly profile: P; + readonly entries: Readonly>; + readonly sourceScopes?: readonly SourceDirectoryRef[]; + readonly virtualModules?: Readonly>; + readonly options?: CompileOptions

; +} + +/** Compiler 输出的受管文件。 */ +export interface CompileOutputFile { + readonly type: 'chunk' | 'asset' | 'licenses'; + readonly outputId: string; + readonly fileName: string; + readonly entryId?: string; + readonly isEntry: boolean; + readonly asset: GeneratedAssetRef; +} + +/** 脱敏后的 Compiler 模块图节点。 */ +export interface CompileModuleReport { + readonly id: string; + readonly kind: 'source' | 'virtual' | 'package'; + readonly inputs: readonly string[]; + readonly importedBy: readonly string[]; +} + +/** Compiler Host 的稳定结果。 */ +export interface CompileResult

{ + readonly job: string; + readonly profile: P; + readonly engine: { readonly name: 'rolldown'; readonly version: string }; + readonly outputs: readonly CompileOutputFile[]; + readonly modules: readonly CompileModuleReport[]; +} + +/** owner-scoped Compiler Host 能力。 */ +export interface CompilerService { + readonly engine: { readonly name: 'rolldown'; readonly version: string }; + /** 通过 Core 唯一 Compiler Host 执行 owner-scoped Job。 */ + compile

(job: CompileJob

): Promise>; +} + +/** Compiler Host 向 Asset Registry 提交的结构化生成来源。 */ +export interface CompileAssetOriginInput { + readonly job: string; + readonly output: string; + readonly profile: CompileProfile; + readonly kind: CompileOutputFile['type']; + readonly inputs: readonly string[]; +} diff --git a/packages/core/src/contracts/components.ts b/packages/core/src/contracts/components.ts new file mode 100644 index 0000000..7dc5d1c --- /dev/null +++ b/packages/core/src/contracts/components.ts @@ -0,0 +1,97 @@ +import type { JsonObject } from './common.js'; +import type { + NodeRuntimeEntryKind, + PluginMetadata, +} from './config.js'; +import type { PortableNodeCompileOptions } from './compiler.js'; +import type { + DiagnosticService, + SourceAssetRef, + SourceFileRef, +} from './services.js'; + +/** 规范 Component 的依赖引用。 */ +export interface ComponentRequires { + readonly skills: readonly string[]; + readonly agents: readonly string[]; +} + +/** Component 正文在安全工程相对路径中的位置。 */ +export interface ComponentLocation { + readonly path: string; + readonly bodyLine: number; +} + +/** 规范 Command。 */ +export interface CommandComponent { + readonly kind: 'command'; + readonly id: string; + readonly description: string; + readonly argumentHint?: string; + readonly body: string; + readonly location: ComponentLocation; + readonly requires: ComponentRequires; + readonly platforms: Readonly>>; +} + +/** 规范 Skill。 */ +export interface SkillComponent { + readonly kind: 'skill'; + readonly id: string; + readonly description: string; + readonly invocation: { readonly user: boolean; readonly model: boolean }; + readonly body: string; + readonly location: ComponentLocation; + readonly requires: ComponentRequires; + readonly platforms: Readonly>>; + readonly auxiliaryFiles: readonly { readonly path: string; readonly asset: SourceAssetRef }[]; +} + +/** Agent 需要的平台中立工具能力。 */ +export type AgentCapability = 'filesystem:read' | 'filesystem:write' | 'search' | 'shell' | 'network' | 'delegate'; + +/** Agent 的平台中立模型级别。 */ +export type AgentModel = 'inherit' | 'fast' | 'capable'; + +/** 规范 Agent。 */ +export interface AgentComponent { + readonly kind: 'agent'; + readonly id: string; + readonly description: string; + readonly model: AgentModel; + readonly capabilities: readonly AgentCapability[]; + readonly body: string; + readonly location: ComponentLocation; + readonly requires: ComponentRequires; + readonly platforms: Readonly>>; +} + +/** Public Provider 发现的资源。 */ +export interface PublicResourceFile { + readonly path: string; + readonly asset: SourceAssetRef; +} + +/** 内建 Runtime Provider 发现的规范入口集合。 */ +export interface NodeRuntimeResource { + readonly target: 'node20'; + readonly entries: readonly { readonly id: string; readonly kind: NodeRuntimeEntryKind; readonly source: SourceFileRef }[]; + readonly compile?: PortableNodeCompileOptions; +} + +/** Scanner 完成验证后的规范工程图。 */ +export interface CanonicalProject { + readonly metadata: PluginMetadata; + readonly commands: readonly CommandComponent[]; + readonly skills: readonly SkillComponent[]; + readonly agents: readonly AgentComponent[]; + readonly publicFiles: readonly PublicResourceFile[]; + readonly runtime?: NodeRuntimeResource; +} + +/** Platform 的 Component 专属字段验证上下文。 */ +export interface PlatformComponentValidationContext { + readonly project: CanonicalProject; + readonly component: CommandComponent | SkillComponent | AgentComponent; + readonly diagnostics: DiagnosticService; +} diff --git a/packages/core/src/contracts/config.ts b/packages/core/src/contracts/config.ts new file mode 100644 index 0000000..c68d36a --- /dev/null +++ b/packages/core/src/contracts/config.ts @@ -0,0 +1,99 @@ +import type { Awaitable } from './common.js'; +import type { PortableNodeCompileOptions } from './compiler.js'; +import type { AcpluginExtension, AcpluginPlatform } from './integrations.js'; + +/** 配置与 BuildSession 支持的命令。 */ +export type ConfigCommand = 'dev' | 'validate' | 'inspect' | 'build'; + +/** 构建执行模式。 */ +export type BuildMode = 'development' | 'production'; + +/** 函数式配置唯一可观察的执行环境。 */ +export interface ConfigEnvironment { + readonly command: ConfigCommand; + readonly mode: BuildMode; +} + +/** Plugin 作者元数据。 */ +export interface PluginAuthor { + readonly name: string; + readonly email?: string; + readonly url?: string; +} + +/** 规范化后的 Plugin 元数据。 */ +export interface PluginMetadata { + readonly name: string; + readonly version: string; + readonly description: string; + readonly displayName?: string; + readonly author?: PluginAuthor; + readonly homepage?: string; + readonly repository?: string; + readonly license?: string; + readonly keywords: readonly string[]; +} + +/** Public 目录中的一条显式来源映射。 */ +export interface PublicCopyRule { + readonly from: string; + readonly to: string; +} + +/** Public 资源的关闭、简写或精确映射配置。 */ +export type PublicConfig = false | string | { + readonly dir?: string; + readonly copy?: readonly PublicCopyRule[]; +}; + +/** Node Runtime 入口的执行意图。 */ +export type NodeRuntimeEntryKind = 'executable' | 'module'; + +/** 作者显式配置的 Node Runtime 入口。 */ +export interface NodeRuntimeEntryInput { + readonly entry: string; + readonly kind?: NodeRuntimeEntryKind; +} + +/** 内建 Node Runtime Resource 的作者配置。 */ +export interface NodeRuntimeConfig { + readonly target?: 'node20'; + readonly entries?: Readonly>; + readonly compile?: PortableNodeCompileOptions; +} + +/** 构建输出和全局兼容性策略。 */ +export interface BuildConfig { + readonly outDir?: string; + readonly strict?: boolean; +} + +/** acplugin.config.ts 的最终作者配置。 */ +export interface UserConfig { + readonly name: string; + readonly version: string; + readonly description: string; + readonly displayName?: string; + readonly author?: PluginAuthor; + readonly homepage?: string; + readonly repository?: string; + readonly license?: string; + readonly keywords?: readonly string[]; + readonly srcDir?: string; + readonly public?: PublicConfig; + readonly runtime?: false | NodeRuntimeConfig; + readonly platforms: readonly AcpluginPlatform[]; + readonly extensions?: readonly AcpluginExtension[]; + readonly build?: BuildConfig; +} + +/** 配置文件允许导出的静态对象或函数。 */ +export type UserConfigExport = UserConfig | ((environment: Readonly) => Awaitable); + +/** 不包含工程路径的已解析配置摘要。 */ +export interface ResolvedConfigSummary { + readonly metadata: Readonly; + readonly command: ConfigCommand; + readonly mode: BuildMode; + readonly strict: boolean; +} diff --git a/packages/core/src/contracts/index.ts b/packages/core/src/contracts/index.ts new file mode 100644 index 0000000..bbdb2b6 --- /dev/null +++ b/packages/core/src/contracts/index.ts @@ -0,0 +1,9 @@ +export * from './common.js'; +export * from './config.js'; +export * from './components.js'; +export * from './integrations.js'; +export * from './services.js'; +export * from './compiler.js'; +export * from './packages.js'; +export * from './reports.js'; +export * from './project.js'; diff --git a/packages/core/src/contracts/integrations.ts b/packages/core/src/contracts/integrations.ts new file mode 100644 index 0000000..08c32cc --- /dev/null +++ b/packages/core/src/contracts/integrations.ts @@ -0,0 +1,314 @@ +import { + LIFECYCLE_API_VERSION, + type Awaitable, + type DocumentFieldPath, + type JsonObject, + type JsonValue, +} from './common.js'; +import type { + BuildMode, + ConfigCommand, + ResolvedConfigSummary, +} from './config.js'; +import type { + CanonicalProject, + PlatformComponentValidationContext, +} from './components.js'; +import type { CompilerService } from './compiler.js'; +import type { + CreatePackageContext, + DistributionContext, + DistributionPackageInput, + FinalizePackageContext, + PackageAssetInput, + PlatformBasePackageSnapshot, + PlatformPackageInput, + PrimaryPackageInput, + ValidatePackageContext, +} from './packages.js'; +import type { + AssetService, + DiagnosticService, + ExecutionService, + ModuleService, + SourceDirectoryRef, + SourceService, +} from './services.js'; + +/** Platform 声明的稳定 Plugin-local Node Runtime 能力。 */ +export type NodeRuntimeCapability = Readonly<{ + target: 'node20'; + format: 'esm'; + root: 'plugin'; +}>; + +/** Platform 供 Framework 和 Extension 协商的只读能力数据。 */ +export type PlatformCapabilities = Readonly<{ + nodeRuntime?: NodeRuntimeCapability; + readonly [capability: string]: JsonValue | NodeRuntimeCapability | undefined; +}>; + +/** Platform 主交付的安装形态。 */ +export type PlatformDeliveryType = 'plugin' | 'workspace' | 'package'; + +/** 仅用于 TypeScript 名义类型的 Platform 品牌,不参与运行时授权。 */ +declare const platformDefinitionTypeBrand: unique symbol; + +/** 仅用于 TypeScript 名义类型的 Extension 品牌,不参与运行时授权。 */ +declare const extensionDefinitionTypeBrand: unique symbol; + +/** + * 仅用于 TypeScript 名义类型的 Platform Component 来源品牌。 + * + * Core 在 merge 时签发其对象 identity;后续 finalization 专属服务会以该 identity + * 作为运行时授权边界。调用方不能以同形普通对象替代它。 + */ +declare const packageComponentOriginTypeBrand: unique symbol; + +/** 作者配置中可安装的 Platform 定义。 */ +export interface PlatformDefinition< + O extends JsonObject = JsonObject, + TComponent extends JsonObject = never, +> { + readonly id: string; + readonly apiVersion: typeof LIFECYCLE_API_VERSION; + readonly deliveryType: PlatformDeliveryType; + readonly strict?: boolean; + readonly options?: O; + readonly capabilities?: PlatformCapabilities; + /** 为当前 BuildSession 创建隔离的平台生命周期状态。 */ + createSession(context: PlatformSetupContext): Awaitable>; +} + +/** 经过工厂校验、复制、品牌化和冻结的 Platform。 */ +export interface AcpluginPlatform< + O extends JsonObject = JsonObject, + TComponent extends JsonObject = never, +> extends PlatformDefinition { + readonly [platformDefinitionTypeBrand]: true; +} + +/** Extension 验证后声明的兼容性覆盖主题。 */ +export interface ExtensionSubject { + readonly subject: string; + readonly capabilities: readonly string[]; +} + +/** Extension validate 阶段的状态与覆盖声明。 */ +export interface ExtensionValidationOutput { + readonly state: Readonly; + readonly subjects: readonly ExtensionSubject[]; +} + +/** Extension build 阶段的不可变 Built State。 */ +export interface ExtensionBuildOutput { + readonly state: Readonly; +} + +/** 作者配置中可安装的 Extension 定义。 */ +export interface ExtensionDefinition< + O extends JsonObject = JsonObject, + D = unknown, + V = D, + B = V, +> { + readonly id: string; + readonly apiVersion: typeof LIFECYCLE_API_VERSION; + readonly options?: O; + readonly resourceRoots: readonly string[]; + /** 为当前 BuildSession 创建隔离的 Extension 生命周期状态。 */ + createSession(context: ExtensionSetupContext): Awaitable>; +} + +/** 经过工厂校验、复制、品牌化和冻结的 Extension。 */ +export interface AcpluginExtension< + O extends JsonObject = JsonObject, + D = unknown, + V = D, + B = V, +> extends ExtensionDefinition { + readonly [extensionDefinitionTypeBrand]: true; +} + +/** Platform 对其他集成公开的稳定身份。 */ +export interface PlatformIntegrationDescription { + readonly kind: 'platform'; + readonly id: string; + readonly apiVersion: typeof LIFECYCLE_API_VERSION; + readonly options?: Readonly; + readonly capabilities?: Readonly; +} + +/** Extension 对其他集成公开的稳定身份。 */ +export interface ExtensionIntegrationDescription { + readonly kind: 'extension'; + readonly id: string; + readonly apiVersion: typeof LIFECYCLE_API_VERSION; + readonly options?: Readonly; + readonly resourceRoots: readonly string[]; +} + +/** 集成只能观察的结构化身份联合类型。 */ +export type IntegrationDescription = PlatformIntegrationDescription | ExtensionIntegrationDescription; + +/** Platform Session 创建上下文。 */ +export interface PlatformSetupContext { + readonly command: ConfigCommand; + readonly mode: BuildMode; + readonly options: Readonly; + readonly config: ResolvedConfigSummary; + readonly integrations: readonly IntegrationDescription[]; +} + +/** Extension Session 创建上下文。 */ +export type ExtensionSetupContext = PlatformSetupContext; + +/** Extension discover 阶段的受限上下文。 */ +export interface ExtensionDiscoverContext { + readonly command: ConfigCommand; + readonly mode: BuildMode; + readonly roots: Readonly>; + readonly sources: SourceService; + readonly modules: ModuleService; + readonly diagnostics: DiagnosticService; +} + +/** Extension validate 阶段的规范工程上下文。 */ +export interface ExtensionValidateContext { + readonly command: ConfigCommand; + readonly mode: BuildMode; + readonly project: CanonicalProject; + readonly diagnostics: DiagnosticService; +} + +/** Extension build 阶段的受管能力上下文。 */ +export interface ExtensionBuildContext extends ExtensionValidateContext { + readonly compiler: CompilerService; + readonly assets: AssetService; + readonly execution: ExecutionService; +} + +/** 集成清理阶段看到的脱敏结果。 */ +export interface IntegrationCloseContext { + readonly outcome: 'success' | 'failed' | 'aborted'; + readonly committed: boolean; + readonly failure?: { readonly code: string; readonly phase: string; readonly message: string }; +} + +/** Platform BuildSession 私有生命周期。 */ +export interface PlatformSession { + /** 校验一个 canonical Component 的平台专属字段。 */ + validateComponent?(context: PlatformComponentValidationContext): Awaitable; + /** 从 canonical project 创建 Platform base Package。 */ + createPackage(context: CreatePackageContext): Awaitable; + /** 从集中合并的 snapshot 确定主 Package。 */ + finalizePackage(context: FinalizePackageContext): Awaitable; + /** 校验 Core 临时物化的完整 Package candidate。 */ + validatePackage(context: ValidatePackageContext): Awaitable; + /** 从已验证主 Package 创建可选 Distribution。 */ + createDistributions?(context: DistributionContext): Awaitable; + /** 在成功、失败或中止后释放当前 Session 状态。 */ + close?(context: IntegrationCloseContext): Awaitable; +} + +/** Extension 对一个 Platform 的无序 add-only Contributor。 */ +export interface PlatformContributor { + readonly platform: string; + readonly platformApiVersion: typeof LIFECYCLE_API_VERSION; + /** 对只读 base Package 返回无序 add-only Contribution。 */ + contribute(context: ContributionContext, built: Readonly): Awaitable>; +} + +/** Extension 向 Platform 提交的一条不透明 JSON Component payload。 */ +export interface PackageComponentInput { + /** 必须精确对应当前 Extension validate() 已声明的 subject。 */ + readonly subject: string; + /** 仅由目标 Platform 理解的严格 JSON object。 */ + readonly value: TComponent; +} + +/** + * Core 签发的 Component provenance identity。 + * + * owner/subject 仅用于审计和稳定诊断;后续消费必须接受 Core 当前 merge 暴露的原始 + * 对象 identity,而不能以同形值伪造来源。 + */ +export interface PackageComponentOrigin { + readonly owner: string; + readonly subject: string; + readonly [packageComponentOriginTypeBrand]: true; +} + +/** 已合并且可供当前 Platform finalization 消费的 Component payload。 */ +export interface ContributedPackageComponent { + readonly value: Readonly; + readonly origin: PackageComponentOrigin; +} + +/** Extension 对一个 Document extension point 的字段贡献。 */ +export interface DocumentFieldContribution { + readonly document: string; + readonly path: DocumentFieldPath; + readonly value: JsonValue; +} + +/** Extension Contributor 的集中合并输入。 */ +export interface PackageContribution { + /** Platform-owned Component 的不透明输入;Core 不读取 value 的业务字段。 */ + readonly components?: readonly PackageComponentInput[]; + readonly documentFields?: readonly DocumentFieldContribution[]; + readonly assets?: readonly PackageAssetInput[]; + readonly compatibility: readonly CompatibilityInput[]; +} + +/** Contributor 只能观察 Platform base snapshot 的上下文。 */ +export interface ContributionContext { + readonly command: ConfigCommand; + readonly mode: BuildMode; + readonly platform: PlatformIntegrationDescription; + readonly project: CanonicalProject; + readonly base: PlatformBasePackageSnapshot; + readonly assets: AssetService; + readonly diagnostics: DiagnosticService; +} + +/** Extension BuildSession 私有生命周期。 */ +export interface ExtensionSession { + /** 从 Extension 独占来源根发现作者资源。 */ + discover(context: ExtensionDiscoverContext): Awaitable; + /** 对发现状态和 canonical project 执行验证。 */ + validate(context: ExtensionValidateContext, discovered: Readonly): Awaitable>; + /** 通过 Core Host 把验证状态构建为跨 Platform Built State。 */ + build(context: ExtensionBuildContext, validated: Readonly): Awaitable>; + /** + * Extension 边界接受异构 Platform payload;具体 Contributor 在其定义处保留 + * Platform 自己的 union 类型,Core 在配置边界擦除为 JsonObject。 + */ + readonly contributors: readonly PlatformContributor[]; + /** 在成功、失败或中止后释放当前 Session 状态。 */ + close?(context: IntegrationCloseContext): Awaitable; +} + +/** Platform 或 Contributor 返回的兼容性结论。 */ +export interface CompatibilityInput { + readonly subject: string; + readonly capability: string; + readonly level: CompatibilityLevel; + readonly transformation?: string; + readonly reason: string; + readonly causes?: readonly string[]; +} + +/** 兼容性支持级别。 */ +export type CompatibilityLevel = 'native' | 'transform' | 'degraded' | 'unsupported'; + +/** 元数据在目标 Package 中的最终去向。 */ +export type MetadataDisposition = 'emitted' | 'omitted'; + +/** Platform 返回的单个元数据处理结论。 */ +export interface MetadataDispositionInput { + readonly field: string; + readonly disposition: MetadataDisposition; + readonly output?: string; + readonly reason: string; +} diff --git a/packages/core/src/contracts/packages.ts b/packages/core/src/contracts/packages.ts new file mode 100644 index 0000000..7561420 --- /dev/null +++ b/packages/core/src/contracts/packages.ts @@ -0,0 +1,190 @@ +import type { + DocumentFieldPath, + JsonObject, + JsonValue, +} from './common.js'; +import type { + BuildMode, + ConfigCommand, +} from './config.js'; +import type { CanonicalProject } from './components.js'; +import type { CompilerService } from './compiler.js'; +import type { + CompatibilityInput, + ContributedPackageComponent, + MetadataDispositionInput, + PackageComponentOrigin, + PlatformDeliveryType, +} from './integrations.js'; +import type { + AssetRef, + AssetService, + BytesAssetRef, + DiagnosticService, + GeneratedBytesOriginInput, +} from './services.js'; + +/** Platform 创建的主 Package 输入。 */ +export interface PlatformPackageInput { + readonly documents: readonly PackageDocumentInput[]; + readonly assets: readonly PackageAssetInput[]; + readonly compatibility: readonly CompatibilityInput[]; + readonly metadata: readonly MetadataDispositionInput[]; +} + +/** Package 中的 Asset 路径映射。 */ +export interface PackageAssetInput { + readonly path: string; + readonly asset: AssetRef; +} + +/** Platform 拥有的结构化 Package Document。 */ +export interface PackageDocumentInput { + readonly id: string; + readonly path: string; + readonly format: 'json' | 'yaml' | 'toml' | 'frontmatter'; + readonly value: Readonly; + readonly emission?: 'required' | 'omit-if-empty'; + readonly extensionPoints: readonly DocumentFieldPath[]; + /** 仅允许当前 Platform 在 finalizePackage() 后写入的精确空字段。 */ + readonly finalizationPoints?: readonly DocumentFieldPath[]; +} + +/** Package snapshot 中保留 issuer 的 Asset。 */ +export interface PackageAssetSnapshot { + readonly path: string; + readonly owner: string; + readonly asset: AssetRef; +} + +/** Package snapshot 中冻结的结构化 Document。 */ +export interface PackageDocumentSnapshot { + readonly id: string; + readonly path: string; + readonly format: PackageDocumentInput['format']; + readonly value: Readonly; + readonly emission: 'required' | 'omit-if-empty'; + readonly extensionPoints: readonly DocumentFieldPath[]; + readonly finalizationPoints: readonly DocumentFieldPath[]; + /** 仅记录决定该 Document finalization field 的可信 Component 来源。 */ + readonly componentOrigins?: readonly PackageComponentOrigin[]; +} + +/** Contributor 只能读取的 Platform base snapshot。 */ +export interface PlatformBasePackageSnapshot { + readonly documents: readonly PackageDocumentSnapshot[]; + readonly assets: readonly PackageAssetSnapshot[]; + readonly compatibility: readonly CompatibilityInput[]; + readonly metadata: readonly MetadataDispositionInput[]; +} + +/** Core 集中合并后的 Package snapshot。 */ +export interface MergedPackageSnapshot extends PlatformBasePackageSnapshot { + /** 仅当前 Platform 的 finalization 可见的、owner/subject-bound opaque payload。 */ + readonly components: readonly ContributedPackageComponent[]; +} + +/** 当前 Platform 对自身预留 Document finalization point 的 add-only 字段贡献。 */ +export interface PlatformFinalizationFieldContribution { + readonly document: string; + readonly path: DocumentFieldPath; + readonly value: JsonValue; + /** 仅当前 finalization scope 签发的 Component origin identity 可用。 */ + readonly componentOrigins?: readonly PackageComponentOrigin[]; +} + +/** Platform finalization 期间生成 Bytes Asset 的扩展 provenance 输入。 */ +export interface FinalizationGeneratedBytesOriginInput extends GeneratedBytesOriginInput { + /** + * 当前 merged Package 中实际消费的 Component 来源。 + * 若同时填写 subjects,每一项都必须来自这些 Component origin 的 subject 集合。 + */ + readonly componentOrigins?: readonly PackageComponentOrigin[]; +} + +/** + * 仅在 finalizePackage() callback 内有效的 Platform AssetService。 + * + * 标准 AssetService 永远不接受 componentOrigins;Core 只把这一窄能力交给当前 + * Platform 的 finalization,随后立即撤销。 + */ +export type FinalizationAssetService = Omit & Readonly<{ + fromBytes(input: { + readonly bytes: Uint8Array | string; + readonly mode?: import('./services.js').AssetMode; + readonly origin: FinalizationGeneratedBytesOriginInput; + }): Promise; +}>; + +/** Platform 最终确定的主 Package 身份和新增 Asset。 */ +export interface PrimaryPackageInput { + readonly id: string; + readonly type: PlatformDeliveryType; + /** 仅当前 Platform 可写入自身预留 finalization point 的 add-only 字段。 */ + readonly documentFields?: readonly PlatformFinalizationFieldContribution[]; + readonly assets?: readonly PackageAssetInput[]; +} + +/** Platform base Package 创建上下文。 */ +export interface CreatePackageContext { + readonly command: ConfigCommand; + readonly mode: BuildMode; + readonly project: CanonicalProject; + readonly compiler: CompilerService; + readonly assets: AssetService; + readonly diagnostics: DiagnosticService; +} + +/** Platform finalization 上下文。 */ +export interface FinalizePackageContext extends Omit { + readonly assets: FinalizationAssetService; + readonly package: MergedPackageSnapshot; +} + +/** 已验证候选中的 Package Unit snapshot。 */ +export interface PackageUnitSnapshot { + readonly platform: string; + readonly id: string; + readonly type: PlatformDeliveryType | 'marketplace'; + readonly role: 'primary' | 'distribution'; + readonly assets: readonly PackageAssetSnapshot[]; + readonly compatibility: readonly CompatibilityInput[]; + readonly metadata: readonly MetadataDispositionInput[]; +} + +/** 临时物化且只在校验调用期间授权的候选。 */ +export interface PackageCandidate { + readonly root: string; + readonly unit: PackageUnitSnapshot; +} + +/** Platform candidate 校验上下文。 */ +export interface ValidatePackageContext { + readonly command: ConfigCommand; + readonly mode: BuildMode; + readonly candidate: PackageCandidate; + readonly diagnostics: DiagnosticService; +} + +/** Distribution 中的一条继承或新增 Asset。 */ +export interface DistributionAssetInput { + readonly path: string; + readonly asset: AssetRef; +} + +/** Marketplace Distribution 输入。 */ +export interface DistributionPackageInput { + readonly id: string; + readonly type: 'marketplace'; + readonly assets: readonly DistributionAssetInput[]; +} + +/** Platform 创建 Distribution 的上下文。 */ +export interface DistributionContext { + readonly command: ConfigCommand; + readonly mode: BuildMode; + readonly project: CanonicalProject; + readonly primary: PackageUnitSnapshot; + readonly assets: AssetService; + readonly diagnostics: DiagnosticService; +} diff --git a/packages/core/src/contracts/project.ts b/packages/core/src/contracts/project.ts new file mode 100644 index 0000000..40372f1 --- /dev/null +++ b/packages/core/src/contracts/project.ts @@ -0,0 +1,62 @@ +import type { BuildMode } from './config.js'; +import type { BuildReport } from './reports.js'; + +/** Project 创建时固定的工程身份选项。 */ +export interface CreateProjectOptions { + readonly cwd?: string; + readonly configFile?: string; +} + +/** 单次 Project 执行选项。 */ +export interface ProjectRunOptions { + readonly command?: 'validate' | 'inspect' | 'build'; + readonly mode?: BuildMode; + readonly platforms?: readonly string[]; + readonly commit?: boolean; +} + +/** 持续构建 Session 选项。 */ +export interface ProjectDevOptions { + readonly mode?: BuildMode; + readonly platforms?: readonly string[]; + readonly commit?: boolean; +} + +/** runProject convenience 的组合选项。 */ +export interface RunProjectOptions extends CreateProjectOptions, ProjectRunOptions {} + +/** DevSession 发布的稳定事件。 */ +export type DevSessionEvent = { + readonly type: 'build-start'; + readonly sequence: number; + readonly changes: readonly string[]; +} | { + readonly type: 'build-complete'; + readonly sequence: number; + readonly changes: readonly string[]; + readonly report: BuildReport; +} | { + readonly type: 'closed'; + readonly sequence: number; + readonly report: BuildReport; +}; + +/** Core 独占 Watch ownership 的持续构建句柄。 */ +export interface DevSession { + /** 最近一次成功报告;首次构建失败时由该初始失败报告暂时播种。 */ + readonly current: BuildReport; + /** 订阅稳定 DevSession 事件并返回取消函数。 */ + subscribe(listener: (event: DevSessionEvent) => void): () => void; + /** 幂等关闭 Watch 与当前 BuildSession;cleanup 失败也会先完成 closed 终态。 */ + close(): Promise; + /** 无论 cleanup 是否失败都在唯一 closed 事件发布后解析。 */ + readonly closed: Promise; +} + +/** 绑定同一工程配置身份的程序化 Project。 */ +export interface Project { + /** 使用固定工程身份执行一次 BuildSession。 */ + run(options?: ProjectRunOptions): Promise; + /** 使用相同 Kernel 创建持续构建 Session。 */ + dev(options?: ProjectDevOptions): Promise; +} diff --git a/packages/core/src/contracts/reports.ts b/packages/core/src/contracts/reports.ts new file mode 100644 index 0000000..bcf6efe --- /dev/null +++ b/packages/core/src/contracts/reports.ts @@ -0,0 +1,139 @@ +import type { + BuildMode, + ConfigCommand, + NodeRuntimeEntryKind, +} from './config.js'; +import type { + CompileOutputFile, + CompileProfile, +} from './compiler.js'; +import type { + CompatibilityInput, + ExtensionSubject, + MetadataDispositionInput, + PlatformDeliveryType, +} from './integrations.js'; +import type { + AssetMode, + DiagnosticInput, + SourceLocation, +} from './services.js'; + +/** 附加 Platform 身份的兼容性报告项。 */ +export interface CompatibilityEntry extends CompatibilityInput { + readonly platform: string; +} + +/** 附加 Platform 身份的元数据报告项。 */ +export interface MetadataDispositionEntry extends MetadataDispositionInput { + readonly platform: string; +} + +/** 生成 Asset 的 contribution provenance;不包含 payload、路径或平台业务类型。 */ +export interface AssetContributor { + readonly owner: string; + readonly subject: string; +} + +/** 稳定报告中的 Asset 来源。 */ +export type AssetOrigin = { + readonly type: 'source'; + readonly resource: string; + readonly path: string; +} | { + readonly type: 'compile'; + readonly owner: string; + readonly job: string; + readonly output: string; + readonly profile: CompileProfile; + readonly kind: CompileOutputFile['type']; + readonly inputs: readonly string[]; +} | { + readonly type: 'generated'; + readonly owner: string; + readonly operation: string; + readonly subjects?: readonly string[]; + readonly contributors?: readonly AssetContributor[]; +}; + +/** BuildReport 中的 Asset 摘要。 */ +export interface PackageAssetReport { + readonly path: string; + readonly owner: string; + readonly mode: AssetMode; + readonly size: number; + readonly sha256: string; + readonly origin: AssetOrigin; +} + +/** BuildReport 中的 Package Unit 摘要。 */ +export interface PackageUnitReport { + readonly platform: string; + readonly id: string; + readonly type: PlatformDeliveryType | 'marketplace'; + readonly role: 'primary' | 'distribution'; + readonly validated: boolean; + readonly assets: readonly PackageAssetReport[]; +} + +/** BuildReport 中的 Component 摘要。 */ +export interface ComponentReport { + readonly kind: 'command' | 'skill' | 'agent'; + readonly id: string; + readonly location: SourceLocation; +} + +/** BuildReport 中的 Runtime 摘要。 */ +export interface RuntimeReport { + readonly id: string; + readonly kind: NodeRuntimeEntryKind; + readonly location: SourceLocation; + readonly built: boolean; +} + +/** BuildReport 中的 Extension 摘要。 */ +export interface ExtensionReport { + readonly id: string; + readonly discovered: boolean; + readonly subjects: readonly ExtensionSubject[]; +} + +/** BuildReport 中的 Platform 摘要。 */ +export interface PlatformReport { + readonly id: string; + readonly selected: boolean; + readonly success: boolean; + readonly packageIds: readonly string[]; +} + +/** 稳定诊断阶段。 */ +export type DiagnosticPhase = 'config' | 'setup' | 'discover' | 'validate' | 'compile' | 'package' | 'contribute' | 'finalize' | 'materialize' | 'platform-validate' | 'compatibility' | 'transaction' | 'cleanup' | 'dev' | 'internal'; + +/** BuildReport 中已绑定来源的诊断。 */ +export interface Diagnostic extends DiagnosticInput { + readonly phase: DiagnosticPhase; + readonly platform?: string; + readonly extension?: string; + readonly owner?: string; + readonly component?: { readonly kind: 'command' | 'skill' | 'agent'; readonly id: string }; + readonly related?: readonly SourceLocation[]; +} + +/** Kernel v2 唯一公开构建报告。 */ +export interface BuildReport { + readonly schemaVersion: 3; + readonly framework: { readonly name: 'acplugin'; readonly version: string }; + readonly compiler: { readonly name: 'rolldown'; readonly version: string }; + readonly success: boolean; + readonly command: ConfigCommand; + readonly mode: BuildMode; + readonly committed: boolean; + readonly components: readonly ComponentReport[]; + readonly runtimes: readonly RuntimeReport[]; + readonly extensions: readonly ExtensionReport[]; + readonly platforms: readonly PlatformReport[]; + readonly packages: readonly PackageUnitReport[]; + readonly compatibility: readonly CompatibilityEntry[]; + readonly metadata: readonly MetadataDispositionEntry[]; + readonly diagnostics: readonly Diagnostic[]; +} diff --git a/packages/core/src/contracts/services.ts b/packages/core/src/contracts/services.ts new file mode 100644 index 0000000..5372be2 --- /dev/null +++ b/packages/core/src/contracts/services.ts @@ -0,0 +1,149 @@ +/** 安全的工程相对来源位置。 */ +export interface SourceLocation { + readonly path: string; + readonly line?: number; + readonly column?: number; +} + +/** 生命周期可以提交的稳定诊断。 */ +export interface DiagnosticInput { + readonly code: string; + readonly severity: 'warning' | 'error'; + readonly message: string; + readonly location?: SourceLocation; + readonly fieldPath?: readonly (string | number)[]; + readonly hint?: string; +} + +/** 绑定 owner 和 phase 的诊断服务。 */ +export interface DiagnosticService { + /** 向当前 owner 和 phase 提交一条结构化诊断。 */ + report(input: DiagnosticInput): void; +} + +/** Core 签发的源码目录能力;运行时授权依赖 Session 对象身份。 */ +declare const sourceDirectoryTypeBrand: unique symbol; + +/** Core 签发的源码文件能力;运行时授权依赖 Session 对象身份。 */ +declare const sourceFileTypeBrand: unique symbol; + +/** Source Registry 签发的来源 Asset 类型品牌。 */ +declare const sourceAssetTypeBrand: unique symbol; + +/** Compiler Host 签发的生成 Asset 类型品牌。 */ +declare const generatedAssetTypeBrand: unique symbol; + +/** Asset Service 签发的内存字节 Asset 类型品牌。 */ +declare const bytesAssetTypeBrand: unique symbol; + +/** Core 签发的源码目录能力;运行时授权依赖 Session 对象身份。 */ +export interface SourceDirectoryRef { + readonly kind: 'source-directory'; + readonly path: string; + readonly [sourceDirectoryTypeBrand]: true; +} + +/** Core 签发的源码文件能力;运行时授权依赖 Session 对象身份。 */ +export interface SourceFileRef { + readonly kind: 'source-file'; + readonly path: string; + readonly [sourceFileTypeBrand]: true; +} + +/** Source Service 返回的已验证目录项。 */ +export type SourceEntry = { + readonly type: 'file'; + readonly name: string; + readonly path: string; + readonly file: SourceFileRef; +} | { + readonly type: 'directory'; + readonly name: string; + readonly path: string; + readonly directory: SourceDirectoryRef; +}; + +/** owner-scoped 源码读取能力。 */ +export interface SourceService { + /** 枚举一个已授权来源目录。 */ + list(directory: SourceDirectoryRef, options?: { readonly recursive?: boolean }): Promise; + /** 从已授权目录签发后代文件 ref。 */ + file(directory: SourceDirectoryRef, relativePath: string): Promise; + /** 从已授权目录签发后代目录 ref。 */ + directory(directory: SourceDirectoryRef, relativePath: string): Promise; + /** 在读取上限内复制来源文件字节。 */ + read(file: SourceFileRef, options?: { readonly maxBytes?: number }): Promise; + /** 在读取上限内以 UTF-8 解码来源文件。 */ + readText(file: SourceFileRef, options?: { readonly maxBytes?: number }): Promise; +} + +/** 可信结构化 ESM 作者模块的加载服务。 */ +export interface ModuleService { + /** 执行受管 ESM 图并返回其 default export。 */ + loadDefault(request: { readonly id: string; readonly entry: SourceFileRef }): Promise; +} + +/** Source Registry 签发的来源 Asset。 */ +export interface SourceAssetRef { + readonly kind: 'source-asset'; + readonly id: string; + readonly [sourceAssetTypeBrand]: true; +} + +/** Compiler Host 签发的生成 Asset。 */ +export interface GeneratedAssetRef { + readonly kind: 'generated-asset'; + readonly id: string; + readonly [generatedAssetTypeBrand]: true; +} + +/** Asset Service 从内存字节签发的 Asset。 */ +export interface BytesAssetRef { + readonly kind: 'bytes-asset'; + readonly id: string; + readonly [bytesAssetTypeBrand]: true; +} + +/** 所有受管 Asset 引用。 */ +export type AssetRef = SourceAssetRef | GeneratedAssetRef | BytesAssetRef; + +/** 受管 Asset 支持的文件权限。 */ +export type AssetMode = 0o644 | 0o755; + +/** Bytes Asset 的稳定生成来源。 */ +export interface GeneratedBytesOriginInput { + readonly operation: string; + readonly subjects?: readonly string[]; +} + +/** owner-scoped Asset 创建与受限读取服务。 */ +export interface AssetService { + /** 从已授权来源文件创建保留来源身份的 Asset。 */ + fromSource(source: SourceFileRef, options?: { readonly mode?: AssetMode }): Promise; + /** 从复制后的内存字节创建带结构化来源的 Asset。 */ + fromBytes(input: { readonly bytes: Uint8Array | string; readonly mode?: AssetMode; readonly origin: GeneratedBytesOriginInput }): Promise; + /** 在 owner grant 和读取上限内复制 Asset 字节。 */ + read(asset: AssetRef, options?: { readonly maxBytes?: number }): Promise; +} + +/** Execution Host 的稳定进程结果。 */ +export interface ExecutionResult { + readonly status: 'exited' | 'signaled' | 'timed-out' | 'output-limit'; + readonly exitCode: number | null; + readonly signal: string | null; + readonly stdout: Uint8Array; + readonly stderr: Uint8Array; +} + +/** owner-scoped Node Execution Host 能力。 */ +export interface ExecutionService { + /** 在隔离 cwd、最小环境和固定资源上限内执行 Node entry。 */ + runNode(request: { + readonly entry: GeneratedAssetRef; + readonly args?: readonly string[]; + readonly stdin?: Uint8Array | string; + readonly timeoutMs: number; + readonly maxOutputBytes: number; + readonly environment?: Readonly>; + }): Promise; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..3ee624f --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,9 @@ +export * from './contracts/index.js'; +export * from './api/definitions.js'; +export * from './output/transaction.js'; +export * from './serialization/index.js'; +export * from './security/json-snapshot.js'; +export * from './compiler/compiler-service.js'; +export * from './project/project.js'; +export * from './lifecycle/build-session.js'; +export * from './config/resolver.js'; diff --git a/packages/core/src/lifecycle/build-environment.ts b/packages/core/src/lifecycle/build-environment.ts new file mode 100644 index 0000000..4851ac9 --- /dev/null +++ b/packages/core/src/lifecycle/build-environment.ts @@ -0,0 +1,66 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { CompilerHost } from '../compiler/compiler-service.js'; +import { AssetRegistry } from '../services/assets.js'; +import { DiagnosticRegistry } from '../services/diagnostics.js'; +import { ExecutionHost } from '../services/execution.js'; +import { ModuleHost } from '../compiler/module-host.js'; +import { SourceRegistry } from '../services/sources.js'; +import { WatchRegistry } from '../services/watch.js'; +import { WorkDirectoryRegistry } from '../services/work-directories.js'; +import { BuildSessionScope } from '../services/session-scope.js'; + +/** Project config loader 与 BuildSession 共享的唯一 Host/Registry 环境。 */ +export interface KernelBuildEnvironment { + readonly scope: BuildSessionScope; + readonly workRoot: string; + readonly sources: SourceRegistry; + readonly workDirectories: WorkDirectoryRegistry; + readonly watch: WatchRegistry; + readonly assets: AssetRegistry; + readonly modules: ModuleHost; + readonly compiler: CompilerHost; + readonly execution: ExecutionHost; + readonly diagnostics: DiagnosticRegistry; +} + +/** 创建一次 BuildSession 唯一的 Host/Registry 图。 */ +export async function createKernelBuildEnvironment(projectRoot: string): Promise { + /** 所有 Integration 中间文件共享一个由 Core 独占的临时父目录。 */ + const workRoot = await fs.mkdtemp(path.join(os.tmpdir(), '.acplugin-work-')); + /** capability scope 在最终报告建立后统一撤销。 */ + const scope = new BuildSessionScope(); + /** Source/Watch/Work registries 是全部 Host 的共同授权基础。 */ + const sources = new SourceRegistry(scope, projectRoot); + /** 每个 owner 只会获得自己的不可伪造 workDir handle。 */ + const workDirectories = new WorkDirectoryRegistry(scope, workRoot); + /** Watch Registry 集中接收 Resource、Module 和 Compiler observations。 */ + const watch = new WatchRegistry(scope, projectRoot); + /** Asset Registry 绑定当前 Source 与 workDir identities。 */ + const assets = new AssetRegistry(scope, sources, workDirectories); + /** Module Host 只读取同一组 Session Registry。 */ + const modules = new ModuleHost({ projectRoot, sources, workDirectories, watch }); + /** Compiler Host 是当前 BuildSession 唯一 Rolldown compile owner。 */ + const compiler = new CompilerHost({ projectRoot, sources, workDirectories, assets, watch }); + /** Execution Host 只运行本 Session 的 portable generated refs。 */ + const execution = new ExecutionHost({ assets, workDirectories }); + return Object.freeze({ + scope, + workRoot, + sources, + workDirectories, + watch, + assets, + modules, + compiler, + execution, + diagnostics: new DiagnosticRegistry(), + }); +} + +/** 撤销全部 capability 并删除当前 Session 中间文件。 */ +export async function disposeKernelBuildEnvironment(environment: KernelBuildEnvironment): Promise { + environment.scope.close(); + await fs.rm(environment.workRoot, { recursive: true, force: true }); +} diff --git a/packages/core/src/lifecycle/build-session.ts b/packages/core/src/lifecycle/build-session.ts new file mode 100644 index 0000000..3f631a2 --- /dev/null +++ b/packages/core/src/lifecycle/build-session.ts @@ -0,0 +1,550 @@ +/** Core 固定生命周期的唯一 BuildSession orchestrator。 */ +import type { + IntegrationDescription, +} from '../contracts/integrations.js'; +import type { + BuildMode, + ConfigCommand, + ResolvedConfigSummary, +} from '../contracts/config.js'; +import type { + BuildReport, + ExtensionReport, + PlatformReport, +} from '../contracts/reports.js'; +import type { CanonicalProject } from '../contracts/components.js'; +import type { PackageUnitSnapshot } from '../contracts/packages.js'; +import type { ProjectRunOptions } from '../contracts/project.js'; +import { commitPackageUnits } from '../output/transaction.js'; +import { CompatibilityRegistry } from '../package/compatibility.js'; +import { createBuildReport } from '../package/report-builder.js'; +import { discoverCanonicalProject } from '../resources/canonical/provider.js'; +import { + buildExtension, + discoverExtension, + preflightExtensionConsumers, + validateExtension, + type BuiltExtensionState, + type ExtensionConsumerPlan, +} from '../resources/extensions.js'; +import { assembleProjectGraph } from '../resources/project-graph.js'; +import { discoverPublicResources } from '../resources/public.js'; +import { ResourceRegistry } from '../resources/registry.js'; +import { + buildNodeRuntime, + discoverNodeRuntime, + platformSupportsNodeRuntime, + type BuiltNodeRuntime, +} from '../resources/runtime/provider.js'; +import type { ResolvedKernelConfig, ResolvedPlatform } from '../config/resolver.js'; +import { compareCodePoints } from '../security/path-policy.js'; +import type { WatchSnapshot } from '../services/watch.js'; +import { + createKernelBuildEnvironment, + disposeKernelBuildEnvironment, + type KernelBuildEnvironment, +} from './build-environment.js'; + +/** Kernel one-shot 执行所需的内部输入。 */ +export interface KernelBuildSessionInput { + readonly config: ResolvedKernelConfig; + readonly frameworkVersion: string; + readonly selection?: readonly string[]; + readonly commit: boolean; + /** 配置加载阶段已经创建的 Session 服务;省略时由本函数完整拥有。 */ + readonly environment?: KernelBuildEnvironment; +} + +/** 内部执行结果为 DevSession 保留安全 Watch snapshot。 */ +export interface KernelBuildSessionResult { + readonly report: BuildReport; + readonly watch: WatchSnapshot; +} + +import { + closeIntegrations, + extensionDescription, + extensionSession, + hasMatchingError, + platformDescription, + platformHasErrors, + platformSession, + projectHasErrors, + reportFailure, + selectPlatforms, + type ExtensionPlanBuildStatus, + type ExtensionRuntime, + type InitializedIntegration, + type PlatformRuntime, +} from './integration-sessions.js'; +import { runPlatformPipeline, validateCompleteMaterialization } from './platform-pipeline.js'; + +/** 建立 Component 的稳定报告列表。 */ +function componentReports(project: CanonicalProject): BuildReport['components'] { + return Object.freeze([...project.commands, ...project.skills, ...project.agents].map(component => Object.freeze({ + kind: component.kind, + id: component.id, + location: Object.freeze({ path: component.location.path, line: component.location.bodyLine }), + }))); +} + +/** + * 执行 Kernel v2 唯一 one-shot BuildSession state machine。 + * + * @param input 已解析配置、选择和事务控制。 + * @returns immutable BuildReport 及 Dev 使用的 Watch snapshot。 + */ +export async function runKernelBuildSession(input: KernelBuildSessionInput): Promise { + /** environment 是否由本次 direct Core 调用创建并负责释放。 */ + const ownEnvironment = input.environment === undefined; + /** 配置 loader 传入的 environment 保证 Config 与 Build 共用一组 Host。 */ + const environment = input.environment ?? await createKernelBuildEnvironment(input.config.projectRoot); + /** 所有 Host/Registry 只从当前唯一 environment 取得。 */ + const { assets, compiler, diagnostics, execution, modules, sources, watch, workDirectories } = environment; + /** 所有报告集合先以空状态存在,确保任一 Kernel 阶段失败仍可形成报告。 */ + let project: CanonicalProject = Object.freeze({ + metadata: input.config.metadata, + commands: Object.freeze([]), skills: Object.freeze([]), agents: Object.freeze([]), publicFiles: Object.freeze([]), + }); + /** setup 成功即压栈,最终只通过 closeIntegrations 消费。 */ + const initialized: InitializedIntegration[] = []; + /** 选中 Platform 在 setup 前完成纯选择校验。 */ + let selected: readonly ResolvedPlatform[] = Object.freeze([]); + /** selection 失败属于唯一阻止 Integration setup 的 config 前置错误。 */ + let selectionValid = true; + /** setup 成功的平台与扩展运行时。 */ + const platforms: PlatformRuntime[] = []; + /** Extension 运行时保持配置顺序。 */ + const extensions: ExtensionRuntime[] = []; + /** Extension 各阶段报告状态。 */ + const extensionReports = new Map(); + /** validated consumer plans 和 built state 在所有 Platform 间共享。 */ + const plans: ExtensionConsumerPlan[] = []; + /** Built State 不允许由其他 Extension 读取。 */ + const built: BuiltExtensionState[] = []; + /** 每个 plan 的 skipped/built/failed 状态阻止 missing State 被误归为 Platform failure。 */ + const planBuilds: ExtensionPlanBuildStatus[] = []; + /** Runtime Built State 只由 Framework contribution 读取。 */ + let builtRuntime: BuiltNodeRuntime | undefined; + /** 完成 primary/distribution candidate 校验的最终 Units。 */ + const units: PackageUnitSnapshot[] = []; + /** 报告中精确标记 validated candidate 的 Unit key。 */ + const validatedPackages = new Set(); + /** 每个 Platform 是否完成全部 package stages。 */ + const platformSucceeded = new Set(); + /** Compatibility Registry 必须等 Project Graph 固定后再创建。 */ + let compatibility: ReturnType = Object.freeze({ compatibility: Object.freeze([]), metadata: Object.freeze([]) }); + /** committed 只在 transaction afterSwap close 全部成功后变为 true。 */ + let committed = false; + /** 防止 commit afterSwap 和 finally cleanup 重复关闭。 */ + let integrationsClosed = false; + + try { + try { + selected = selectPlatforms(input.config, input.selection); + } catch { + selectionValid = false; + reportFailure(diagnostics, 'config', 'PLATFORM_SELECTION_INVALID', 'Selected Platforms are invalid.'); + } + /** integrations snapshot 在任何 factory 调用前固定。 */ + const platformDescriptions = selected.map(platformDescription); + /** Extension descriptions 与 Platform descriptions 共同形成只读 setup 视图。 */ + const extensionDescriptions = input.config.extensions.map(extensionDescription); + /** integrations 不包含 Session 或可变配置引用。 */ + const integrations: readonly IntegrationDescription[] = Object.freeze([...platformDescriptions, ...extensionDescriptions]); + /** setup Context 不暴露物理路径或 mutable config。 */ + const summary: ResolvedConfigSummary = Object.freeze({ + metadata: input.config.metadata, + command: input.config.command, + mode: input.config.mode, + strict: input.config.strict, + }); + + /** Platform Session 必须先按配置顺序逐一创建。 */ + for (const [index, resolved] of selected.entries()) { + /** description 与当前 resolved Platform 使用相同配置槽位。 */ + const description = platformDescriptions[index]!; + try { + /** session 一经 shape 校验即进入 initialized close stack。 */ + const session = platformSession(await resolved.definition.createSession(Object.freeze({ + command: input.config.command, + mode: input.config.mode, + options: resolved.definition.options ?? Object.freeze({}), + config: summary, + integrations, + })), resolved.definition.id); + platforms.push(Object.freeze({ resolved, description, session })); + initialized.push(Object.freeze({ kind: 'platform', id: resolved.definition.id, session })); + } catch { + reportFailure(diagnostics, 'setup', 'PLATFORM_SETUP_FAILED', `Platform "${resolved.definition.id}" setup failed.`, { + owner: `platform:${resolved.definition.id}`, platform: resolved.definition.id, + }); + } + } + /** Extension Session 在 Platform setup 尝试结束后按配置顺序独立创建。 */ + if (selectionValid) { + for (const [index, definition] of input.config.extensions.entries()) { + /** description 与当前 Extension 使用相同配置槽位。 */ + const description = extensionDescriptions[index]!; + try { + /** session 一经 shape 校验即进入 initialized close stack。 */ + const session = extensionSession(await definition.createSession(Object.freeze({ + command: input.config.command, + mode: input.config.mode, + options: definition.options ?? Object.freeze({}), + config: summary, + integrations, + })), definition.id); + extensions.push(Object.freeze({ definition, description, session })); + initialized.push(Object.freeze({ kind: 'extension', id: definition.id, session })); + extensionReports.set(definition.id, Object.freeze({ id: definition.id, discovered: false, subjects: Object.freeze([]) })); + } catch { + reportFailure(diagnostics, 'setup', 'EXTENSION_SETUP_FAILED', `Extension "${definition.id}" setup failed.`, { + owner: `extension:${definition.id}`, extension: definition.id, + }); + } + } + } + + if (selectionValid) { + /** Resource claims 固定 canonical/runtime/Extension root ownership。 */ + const claims = await new ResourceRegistry({ config: input.config, sources, watch, diagnostics }).claim(); + /** 独立 Resource discover 共享 registries,但不共享 mutable State。 */ + const canonicalPromise = discoverCanonicalProject({ + metadata: input.config.metadata, + platformIds: selected.map(platform => platform.definition.id), + claims, + sources, + assets, + diagnostics, + }); + /** Public Provider 与 canonical/runtime discover 并行且无共享 mutable state。 */ + const publicPromise = discoverPublicResources({ config: input.config, sources, assets, watch, diagnostics }); + /** Runtime Provider 当前只发现 framework-owned entry state。 */ + const runtimePromise = discoverNodeRuntime({ + ...(claims.runtime === undefined ? {} : { root: claims.runtime }), + config: input.config.runtime, + sources, + diagnostics, + }); + /** 每个 Extension discover 独立捕获并绑定自己的失败身份。 */ + const discoveredExtensions = extensions.map(async (runtime) => { + try { + /** discovered State 立即通过 Extension Provider 建立 owner-bound snapshot。 */ + const discovered = await discoverExtension({ + extension: runtime.definition, + session: runtime.session, + roots: claims.extensions[runtime.definition.id] ?? Object.freeze({}), + command: input.config.command, + mode: input.config.mode, + sources, + assets, + modules: modules.service(`extension:${runtime.definition.id}`), + diagnostics, + }); + /** discover 主动报告 error 与 throw 使用相同失败语义。 */ + if (hasMatchingError(diagnostics, item => item.extension === runtime.definition.id)) + return Object.freeze({ runtime, discovered: undefined }); + return Object.freeze({ runtime, discovered }); + } catch { + reportFailure(diagnostics, 'discover', 'EXTENSION_DISCOVER_FAILED', `Extension "${runtime.definition.id}" discover failed.`, { + owner: `extension:${runtime.definition.id}`, extension: runtime.definition.id, + }); + return Object.freeze({ runtime, discovered: undefined }); + } + }); + /** 聚合只按 Promise 输入槽位读取,不观察完成顺序。 */ + const [canonical, publicFiles, runtime, discovered] = await Promise.all([ + canonicalPromise, publicPromise, runtimePromise, Promise.all(discoveredExtensions), + ]); + /** 唯一 Project Graph 在全部 Resource discover 后一次性冻结。 */ + project = assembleProjectGraph(canonical, publicFiles, runtime); + + /** Stage 5 对每个 canonical Component/selected Platform 恰好调用一次 hook。 */ + const components = [...project.commands, ...project.skills, ...project.agents]; + /** 独立验证并行运行,诊断由 Registry 稳定排序。 */ + await Promise.all(platforms.flatMap(platform => components.map(async (component) => { + try { + await platform.session.validateComponent?.(Object.freeze({ + project, + component, + diagnostics: diagnostics.service('validate', { + owner: `platform:${platform.description.id}`, + platform: platform.description.id, + component: { kind: component.kind, id: component.id }, + }), + })); + } catch { + reportFailure(diagnostics, 'validate', 'PLATFORM_COMPONENT_VALIDATION_FAILED', + `Platform "${platform.description.id}" could not validate ${component.kind} "${component.id}".`, { + owner: `platform:${platform.description.id}`, + platform: platform.description.id, + component: { kind: component.kind, id: component.id }, + }); + } + }))); + + /** Extension validate 只运行实际发现了作者资源的 State。 */ + const validated = await Promise.all(discovered.map(async ({ runtime: extension, discovered: state }) => { + if (state === undefined) + return undefined; + try { + /** result 立即跨越 Extension State snapshot 与 subject contract。 */ + const result = await validateExtension({ + discovered: state, + session: extension.session, + project, + command: input.config.command, + mode: input.config.mode, + sources, + assets, + diagnostics, + }); + extensionReports.set(extension.definition.id, Object.freeze({ + id: extension.definition.id, + discovered: true, + subjects: result.subjects, + })); + if (hasMatchingError(diagnostics, diagnostic => diagnostic.extension === extension.definition.id)) + return undefined; + return Object.freeze({ runtime: extension, validated: result }); + } catch { + reportFailure(diagnostics, 'validate', 'EXTENSION_VALIDATE_FAILED', `Extension "${extension.definition.id}" validate failed.`, { + owner: `extension:${extension.definition.id}`, extension: extension.definition.id, + }); + return undefined; + } + })); + + /** consumer preflight 在 build 前固定 missing contributor/skip 语义。 */ + for (const item of validated) { + if (item === undefined) + continue; + try { + plans.push(preflightExtensionConsumers({ + validated: item.validated, + session: item.runtime.session, + platforms: platformDescriptions, + })); + } catch { + reportFailure(diagnostics, 'validate', 'EXTENSION_CONTRIBUTOR_INVALID', `Extension "${item.runtime.definition.id}" contributors are invalid.`, { + owner: `extension:${item.runtime.definition.id}`, extension: item.runtime.definition.id, + }); + } + } + + /** Stage 6 只构建拥有至少一个 consumer 的 Extension。 */ + const buildResults = await Promise.all(plans.map(async (plan) => { + if (!plan.requiresBuild) + return Object.freeze({ plan, status: 'skipped' as const }); + /** runtime 仅用于取得当前 plan 自己的 Session。 */ + const runtime = extensions.find(item => item.definition.id === plan.extension.id)!; + try { + /** result 在写入 shared built array 前保持 plan 槽位顺序。 */ + const result = await buildExtension({ + plan, + session: runtime.session, + project, + command: input.config.command, + mode: input.config.mode, + compiler: await compiler.service(`extension:${plan.extension.id}`), + execution: execution.service(`extension:${plan.extension.id}`), + assets, + sources, + diagnostics, + }); + return hasMatchingError(diagnostics, diagnostic => diagnostic.extension === plan.extension.id) || result === undefined + ? Object.freeze({ plan, status: 'failed' as const }) + : Object.freeze({ plan, status: 'built' as const, built: result }); + } catch { + reportFailure(diagnostics, 'compile', 'EXTENSION_BUILD_FAILED', `Extension "${plan.extension.id}" build failed.`, { + owner: `extension:${plan.extension.id}`, extension: plan.extension.id, + }); + return Object.freeze({ plan, status: 'failed' as const }); + } + })); + planBuilds.push(...buildResults); + built.push(...buildResults + .filter((value): value is Extract => value.status === 'built') + .map(value => value.built)); + + /** Runtime 只在至少一个选中且已 setup Platform 声明能力时编译一次。 */ + if (project.runtime !== undefined && platforms.some(platform => platformSupportsNodeRuntime(platform.description))) { + try { + builtRuntime = await buildNodeRuntime(project.runtime, await compiler.service('framework:node-runtime')); + } catch { + reportFailure(diagnostics, 'compile', 'NODE_RUNTIME_BUILD_FAILED', 'Node Runtime compilation failed.', { + owner: 'framework:node-runtime', + }); + } + } + + /** Package stages 对 selected Platforms 独立执行;报告合并按稳定键完成。 */ + const projectFailed = projectHasErrors(diagnostics); + /** packageResults 保留 Platform 配置槽位,与并发完成顺序无关。 */ + const packageResults = projectFailed + ? Object.freeze([]) + : await Promise.all(platforms.map(platform => runPlatformPipeline({ + platform, + project, + runtime: builtRuntime, + plans, + built, + planBuilds, + command: input.config.command, + mode: input.config.mode, + compiler, + assets, + workDirectories, + diagnostics, + }))); + + /** 每个 Platform 独立完成 compatibility graph,错误不抑制其他 Platform。 */ + const compatibilityEntries: typeof compatibility.compatibility[number][] = []; + /** metadata dispositions 与 compatibility 使用相同 Platform 隔离。 */ + const metadataEntries: typeof compatibility.metadata[number][] = []; + for (const result of packageResults) { + if (result === undefined) + continue; + units.push(...result.units); + for (const unit of result.units) + validatedPackages.add(`${unit.platform}/${unit.id}`); + /** id 固定当前独立 compatibility Registry 的 Platform identity。 */ + const id = result.platform.description.id; + try { + /** registry 只接收当前 Platform 的 graph,避免跨平台失败抑制。 */ + const registry = new CompatibilityRegistry({ project, diagnostics }); + registry.addCompatibility(id, result.merged.compatibility); + registry.addMetadata(id, result.merged.metadata); + /** finalized 在完整当前 Platform graph 上执行一次 strictness。 */ + const finalized = registry.finalize([Object.freeze({ id, strict: result.platform.resolved.strict })]); + compatibilityEntries.push(...finalized.compatibility); + metadataEntries.push(...finalized.metadata); + if (!platformHasErrors(diagnostics, id)) + platformSucceeded.add(id); + } catch { + reportFailure(diagnostics, 'compatibility', 'COMPATIBILITY_FINALIZATION_FAILED', + `Platform "${id}" compatibility finalization failed.`, { platform: id, owner: `platform:${id}` }); + } + } + compatibility = Object.freeze({ + compatibility: Object.freeze(compatibilityEntries), + metadata: Object.freeze(metadataEntries), + }); + /** 无 commit 或已有错误时不会进入 transaction,必须在此完成 aggregate 复核。 */ + if (!input.commit || diagnostics.hasErrors) { + try { + await validateCompleteMaterialization(units, assets, environment.workRoot); + } catch { + reportFailure(diagnostics, 'materialize', 'PACKAGE_MATERIALIZATION_FAILED', 'Complete Package materialization failed.'); + } + } + } + + /** validate/inspect 已由 Project 层强制 commit=false;错误报告也绝不进入事务。 */ + if (input.commit && !diagnostics.hasErrors) { + try { + await commitPackageUnits(input.config.outDirectory, units, assets, { + projectRoot: input.config.projectRoot, + scope: input.selection === undefined + ? Object.freeze({ type: 'full' as const }) + : Object.freeze({ type: 'subset' as const, platforms: Object.freeze(selected.map(item => item.definition.id)) }), + /** close 属于 swap 后仍可 rollback 的 commit 必要条件。 */ + afterSwap: async () => { + try { + await closeIntegrations(initialized, diagnostics, true); + } finally { + /** stack 已消费,即使 close 失败也不能在 rollback 后重复调用。 */ + integrationsClosed = true; + } + }, + }); + committed = true; + } catch { + if (!diagnostics.diagnostics.some(item => item.phase === 'cleanup')) + reportFailure(diagnostics, 'transaction', 'TRANSACTION_FAILED', 'Managed output transaction failed.'); + } + } + } catch { + reportFailure(diagnostics, 'internal', 'INTERNAL_ERROR', 'The Kernel could not complete the BuildSession.'); + } finally { + if (!integrationsClosed) { + try { + await closeIntegrations(initialized, diagnostics, false); + } catch { + /** closeIntegrations 已记录每个 cleanup failure。 */ + } + } + } + + /** Platform/Extension 未 setup 或未选中状态也必须显式出现在稳定报告。 */ + for (const extension of input.config.extensions) { + if (!extensionReports.has(extension.id)) + extensionReports.set(extension.id, Object.freeze({ id: extension.id, discovered: false, subjects: Object.freeze([]) })); + } + /** selectedIds 用于报告配置中未选 Platform 的显式状态。 */ + const selectedIds = new Set(selected.map(platform => platform.definition.id)); + /** platformReports 从配置全集稳定投影,不从成功 Unit 反推选择状态。 */ + const platformReports: PlatformReport[] = input.config.platforms.map(platform => Object.freeze({ + id: platform.definition.id, + selected: selectedIds.has(platform.definition.id), + success: platformSucceeded.has(platform.definition.id) && !diagnostics.diagnostics.some(item => item.platform === platform.definition.id && item.severity === 'error'), + packageIds: Object.freeze(units.filter(unit => unit.platform === platform.definition.id).map(unit => unit.id).sort(compareCodePoints)), + })); + /** BuildSession 成功同时要求无诊断、全部选中 Platform 成功和必要提交完成。 */ + const success = !diagnostics.hasErrors + && selected.every(platform => platformSucceeded.has(platform.definition.id)) + && (!input.commit || committed); + /** Report 必须在 capability scope 撤销和 workDir 删除前读取 Asset provenance。 */ + const report = createBuildReport({ + frameworkVersion: input.frameworkVersion, + compilerVersion: (await compiler.service('framework:report')).engine.version, + success, + command: input.config.command, + mode: input.config.mode, + committed, + components: componentReports(project), + runtimes: Object.freeze((project.runtime?.entries ?? []).map(entry => Object.freeze({ + id: entry.id, kind: entry.kind, location: Object.freeze({ path: entry.source.path }), + built: builtRuntime?.entries.some(candidate => candidate.id === entry.id) ?? false, + }))), + extensions: Object.freeze([...extensionReports.values()]), + platforms: Object.freeze(platformReports), + packages: Object.freeze(units), + validatedPackages: Object.freeze([...validatedPackages]), + compatibility: compatibility.compatibility, + metadata: compatibility.metadata, + diagnostics: diagnostics.diagnostics, + assets, + }); + /** Watch snapshot 在关闭 capability scope 前完成不可变复制。 */ + const watchSnapshot = watch.snapshot(); + if (ownEnvironment) + await disposeKernelBuildEnvironment(environment); + return Object.freeze({ report, watch: watchSnapshot }); +} + +/** Project 层规范化 one-shot command/mode/commit defaults。 */ +export function normalizeProjectRunOptions(options: ProjectRunOptions = {}): { + readonly command: Exclude; + readonly mode: BuildMode; + readonly selection?: readonly string[]; + readonly commit: boolean; +} { + /** command 缺省为唯一可提交的一次性 build。 */ + const command = options.command ?? 'build'; + if (command !== 'validate' && command !== 'inspect' && command !== 'build') + throw new TypeError('Project command must be validate, inspect or build.'); + /** mode 只进入 ConfigEnvironment,不改变 command/commit 规则。 */ + const mode = options.mode ?? 'production'; + if (mode !== 'development' && mode !== 'production') + throw new TypeError('Project mode must be development or production.'); + if (options.commit !== undefined && typeof options.commit !== 'boolean') + throw new TypeError('Project commit must be boolean.'); + return Object.freeze({ + command, + mode, + ...(options.platforms === undefined ? {} : { selection: Object.freeze([...options.platforms]) }), + commit: command === 'build' && (options.commit ?? true), + }); +} diff --git a/packages/core/src/lifecycle/dev-session.ts b/packages/core/src/lifecycle/dev-session.ts new file mode 100644 index 0000000..d95917a --- /dev/null +++ b/packages/core/src/lifecycle/dev-session.ts @@ -0,0 +1,573 @@ +/** DevSession 只协调重复创建唯一 BuildSession。 */ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { watch, type FSWatcher } from 'chokidar'; +import type { + BuildReport, + Diagnostic, +} from '../contracts/reports.js'; +import type { + DevSession, + DevSessionEvent, + ProjectDevOptions, +} from '../contracts/project.js'; +import { createBuildReport } from '../package/report-builder.js'; +import { + runKernelBuildSession, + type KernelBuildSessionResult, +} from './build-session.js'; +import { + createKernelBuildEnvironment, + disposeKernelBuildEnvironment, +} from './build-environment.js'; + +/** Dev coordinator 向配置 loader 请求的固定命令。 */ +export interface DevSessionRoundInput { + readonly projectRoot: string; + readonly configFile?: string; + readonly frameworkVersion: string; + readonly loadConfig: (environment: Awaited>) => Promise; + readonly options: ProjectDevOptions; + readonly initialConfigError?: (error: unknown) => readonly Diagnostic[]; + /** Core 单测使用的 watcher I/O 注入点;公开 Project API 不暴露。 */ + readonly watchFactory?: typeof watch; + /** Core 单测可缩短 readiness fault 的有界等待;默认 5 秒。 */ + readonly watchReadyTimeoutMs?: number; +} + +/** Project 配置失败时构造一个可继续 watch 的最小报告。 */ +async function failureReport( + input: DevSessionRoundInput, + environment: Awaited>, + diagnostics: readonly Diagnostic[], +): Promise { + return createBuildReport({ + frameworkVersion: input.frameworkVersion, + compilerVersion: (await environment.compiler.service('framework:report')).engine.version, + success: false, + command: 'dev', + mode: input.options.mode ?? 'development', + committed: false, + components: [], + runtimes: [], + extensions: [], + platforms: [], + packages: [], + compatibility: [], + metadata: [], + diagnostics, + assets: environment.assets, + }); +} + +/** 使用独立受管环境建立一个不泄漏 watcher 异常的稳定失败报告。 */ +async function isolatedFailureReport( + input: DevSessionRoundInput, + diagnostic: Diagnostic, +): Promise { + /** 失败报告仍使用独占环境取得完整且安全的 schema-v3 字段。 */ + const environment = await createKernelBuildEnvironment(input.projectRoot); + try { + return await failureReport(input, environment, Object.freeze([diagnostic])); + } finally { + await disposeKernelBuildEnvironment(environment); + } +} + +/** 从文件事件生成工程相对路径或已登记的外部 package identity。 */ +function changeIdentity( + projectRoot: string, + file: string, + observations: ReadonlyMap>, +): string | undefined { + /** Chokidar 与 Watch Registry 均使用绝对规范路径。 */ + const absolute = path.resolve(file); + /** 精确依赖优先复用 Watch Registry 已验证的稳定 identity。 */ + const direct = observations.get(absolute); + if (direct !== undefined) + return direct.identity; + /** 工程根中的新资源尚未进入 snapshot,仍可安全使用相对路径。 */ + const projectRelative = path.relative(projectRoot, absolute); + if (projectRelative === '' || (projectRelative !== '..' && !projectRelative.startsWith(`..${path.sep}`) && !path.isAbsolute(projectRelative))) + return projectRelative === '' ? '.' : projectRelative.split(path.sep).join('/'); + /** 外部目录 observation 可以为其后代生成同一 package identity 下的路径。 */ + const directory = [...observations.entries()] + .filter(([root, observation]) => observation.type === 'directory' && (() => { + /** relative 用于证明事件仍位于已授权的外部观察目录内。 */ + const relative = path.relative(root, absolute); + return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)); + })()) + .sort(([left], [right]) => right.length - left.length)[0]; + if (directory === undefined) + return undefined; + /** 最深匹配目录的安全后代路径附加到其稳定 package identity。 */ + const suffix = path.relative(directory[0], absolute).split(path.sep).join('/'); + return suffix === '' ? directory[1].identity : `${directory[1].identity}/${suffix}`; +} + +/** + * 只忽略本轮解析后的托管输出和仓库元数据。 + * + * @param projectRoot 固定工程根。 + * @param outputDirectory 最近一次合法配置解析出的输出根。 + * @param candidate Chokidar 正在判定的路径。 + * @returns 该路径是否不应触发重建。 + */ +function ignoredPath(projectRoot: string, outputDirectory: string | undefined, candidate: string): boolean { + /** 工程相对路径只用于判断元数据和中间目录。 */ + const relative = path.relative(projectRoot, candidate).split(path.sep).join('/'); + /** 输出路径按物理包含关系判断,不把名为 dist 的合法 srcDir 特判掉。 */ + const outputRelative = outputDirectory === undefined ? undefined : path.relative(outputDirectory, candidate); + /** 候选位于最终输出根本身或后代时必须忽略。 */ + const managedOutput = outputRelative !== undefined && (outputRelative === '' + || (!outputRelative.startsWith(`..${path.sep}`) && outputRelative !== '..' && !path.isAbsolute(outputRelative))); + /** 输出事务的 lock/stage/backup/record 位于 outDir 同级,同样由 Core 托管。 */ + const outputBase = outputDirectory === undefined ? undefined : path.basename(outputDirectory); + /** 事务辅助路径共用以 outDir basename 为前缀的稳定命名。 */ + const transactionPrefix = outputBase === undefined ? undefined : `.${outputBase}.acplugin`; + /** 仅匹配 outDir 父目录中的直属事务路径。 */ + const candidateParent = path.dirname(candidate); + /** 候选 basename 用于区分作者目录与托管事务元数据。 */ + const candidateBase = path.basename(candidate); + /** 同级事务路径不得反向触发 dev 重建。 */ + const managedTransaction = outputDirectory !== undefined && transactionPrefix !== undefined + && candidateParent === path.dirname(outputDirectory) + && (candidateBase === `${transactionPrefix}.lock` + || candidateBase === `${transactionPrefix}-transaction.json` + || candidateBase === `${transactionPrefix}-transaction.json.writing` + || candidateBase === `${transactionPrefix}-committed.json` + || candidateBase === `${transactionPrefix}-committed.json.writing` + || candidateBase === `${transactionPrefix}-backup` + || candidateBase.startsWith(`${transactionPrefix}-stage-`)); + /** 不跟随的包代理内嵌 node_modules symlink 不是作者变更。 */ + const nestedDependencyLink = relative !== 'node_modules' && relative.endsWith('/node_modules'); + return managedOutput || managedTransaction || nestedDependencyLink || relative === '.git' || relative.startsWith('.git/') + || relative.startsWith('.acplugin-work-') || relative.startsWith('.acplugin-stage-'); +} + +/** Core-owned DevSession 的最小 round coordinator。 */ +export async function createDevSession(input: DevSessionRoundInput): Promise { + /** 生产路径始终使用 Chokidar;测试只替换同一 FSWatcher 契约。 */ + const createWatcher = input.watchFactory ?? watch; + /** readiness deadline 必须是有限正整数。 */ + const watchReadyTimeoutMs = input.watchReadyTimeoutMs ?? 5_000; + if (!Number.isSafeInteger(watchReadyTimeoutMs) || watchReadyTimeoutMs <= 0) + throw new TypeError('Dev watcher readiness timeout must be a positive integer.'); + /** Platform subset 在 Session 创建时复制,不观察调用方后续修改。 */ + const selection = input.options.platforms === undefined ? undefined : Object.freeze([...input.options.platforms]); + /** Dev 默认提交成功输出。 */ + const commit = input.options.commit ?? true; + /** 订阅者只接收不可变轮次事件。 */ + const listeners = new Set<(event: DevSessionEvent) => void>(); + /** Core 独占的当前文件观察器。 */ + let watcher: FSWatcher | undefined; + /** 已向 Chokidar 登记的路径快照。 */ + let watchedPaths = new Set(); + /** 首次 ready 时间用于保留最小事件交付窗口。 */ + let watcherReadyAt = 0; + /** close 完成后的终态标志。 */ + let closed = false; + /** close 已开始但在途轮次尚未排空的标志。 */ + let closing = false; + /** 轮次事件的单调序号。 */ + let sequence = 0; + /** active 轮次后是否需要一次补偿构建。 */ + let pending = false; + /** 首轮及 watcher 对齐尚未完成的标志。 */ + let initializing = true; + /** 待合并到下一轮的工程相对变更。 */ + let pendingChanges = new Set(); + /** 唯一在途的 drain Promise。 */ + let active: Promise | undefined; + /** 空闲期文件事件的短窗口合并计时器。 */ + let debounce: ReturnType | undefined; + /** 最近一轮构建已经读取并登记的物理路径。 */ + let knownBuildPaths = new Set(); + /** 物理 watch path 到安全 change identity/type 的当前映射。 */ + let knownObservations = new Map>(); + /** 最近一次成功或首轮失败的可公开报告。 */ + let current: BuildReport; + /** 配置入口轮询的 mtime/size 组合。 */ + let configStamp: string | undefined; + /** 配置首次解析前不猜测输出根,每轮解析后立即更新。 */ + let outputDirectory: string | undefined; + /** 配置缺失或替换时的保守恢复轮询器。 */ + const poller = input.configFile === undefined + ? undefined + : setInterval(async () => { + if (closed || closing) + return; + /** 当前配置普通文件状态;缺失时映射为空 stamp。 */ + const stat = await fs.stat(input.configFile!).catch(() => undefined); + /** 轮询不读取配置内容,只比较稳定文件元数据。 */ + const stamp = stat === undefined ? '' : `${stat.mtimeMs}:${stat.size}`; + if (configStamp !== undefined && stamp !== configStamp) { + configStamp = stamp; + schedule(input.configFile!); + } else if (configStamp === undefined) { + configStamp = stamp; + if (stamp !== '' && current !== undefined && !current.success) + schedule(input.configFile!); + } + }, 100); + /** close() 完成时解析公开 closed Promise 的函数。 */ + let resolveClosed!: () => void; + /** 调用方可等待的唯一 Session 关闭信号。 */ + const closedPromise = new Promise((resolve) => { + resolveClosed = resolve; + }); + /** 所有并发 close() 调用共享的唯一关闭任务。 */ + let closeTask: Promise | undefined; + + /** 向当前订阅者隔离发布事件。 */ + const emit = (event: DevSessionEvent): void => { + for (const listener of [...listeners]) { + try { + listener(event); + } catch { + /** 异常订阅者自动撤销,不能反复影响后续事件分发。 */ + listeners.delete(listener); + } + } + }; + + /** 空闲时在短暂安静窗口后启动唯一 drain。 */ + const requestDrain = (): void => { + if (closed || closing || initializing || active !== undefined) + return; + if (debounce !== undefined) + clearTimeout(debounce); + debounce = setTimeout(() => { + debounce = undefined; + /** round 自身收敛已知错误;最后防线仍显式观察未知 rejection。 */ + void drain().catch(() => undefined); + }, 100); + }; + + /** 将一个文件事件合并到最多一次补偿轮次。 */ + const schedule = (file: string, event: string = 'change'): void => { + if (closed || closing || ignoredPath(input.projectRoot, outputDirectory, file)) + return; + /** 动态 watcher.add() 会对本轮已读取路径延迟交付 add/addDir,它们不是新变更。 */ + const knownAdd = (event === 'add' || event === 'addDir') && [...knownBuildPaths].some((known) => { + /** pending 文件的首次 add 是真实恢复事件,不能作为 watcher 合成事件丢弃。 */ + if (known === file) + return knownObservations.get(known)?.pending !== true; + if (event !== 'addDir') + return false; + /** addDir 候选是已知文件的祖先时同样是合成 ready 事件。 */ + const relative = path.relative(file, known); + return knownObservations.get(known)?.pending !== true && relative !== '' && relative !== '..' + && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); + }); + if (knownAdd) + return; + /** 工程外事件必须已经具有 Watch Registry 签发的逻辑 identity。 */ + const identity = changeIdentity(input.projectRoot, file, knownObservations); + if (identity === undefined) + return; + pending = true; + pendingChanges.add(identity); + requestDrain(); + }; + + /** 等待动态增加的精确依赖进入 Chokidar 快照。 */ + const waitUntilWatched = async (paths: readonly string[]): Promise => { + if (paths.length === 0 || watcher === undefined) + return; + /** 有限期 ready 窗口避免关闭永久挂起。 */ + const deadline = Date.now() + watchReadyTimeoutMs; + while (Date.now() < deadline) { + /** Chokidar 当前目录到直属条目的观察快照。 */ + const watched = watcher.getWatched(); + /** 所有新路径都出现在快照中才可对外发布成功。 */ + const ready = paths.every((candidate) => { + /** 快照索引使用物理父目录。 */ + const directory = path.dirname(candidate); + /** 父目录下匹配的精确文件名。 */ + const basename = path.basename(candidate); + return Array.isArray(watched[directory]) && watched[directory].includes(basename); + }); + if (ready) + return; + await new Promise(resolve => setTimeout(resolve, 10)); + } + throw new Error('Dev watcher did not become ready for the build dependency graph.'); + }; + + /** 用最新 Module/Compiler/Resource 图对齐唯一 watcher。 */ + const updateWatcher = async (result: KernelBuildSessionResult, replace: boolean): Promise => { + /** 已解析依赖中排除当前托管输出。 */ + const roundObservations = result.watch.observations + .filter(observation => !ignoredPath(input.projectRoot, outputDirectory, observation.path)); + /** 成功轮原子替换图;失败轮与 last-good 图取并集以保留全部恢复入口。 */ + const nextObservations = replace + ? new Map>() + : new Map(knownObservations); + for (const observation of roundObservations) + nextObservations.set(observation.path, Object.freeze({ + identity: observation.identity, + type: observation.type, + pending: observation.pending, + })); + /** watcher 物理输入与公开 identity 映射来自同一个待提交 snapshot。 */ + const exact = [...nextObservations.keys()]; + /** 工程根用于发现新资源,精确路径用于覆盖外部依赖。 */ + const desired = [...new Set([input.projectRoot, ...exact])].sort(); + if (watcher === undefined) { + watcher = createWatcher(desired, { + ignoreInitial: true, + followSymlinks: false, + /** 忽略策略读取当前动态输出根。 */ + ignored: (candidate: string) => ignoredPath(input.projectRoot, outputDirectory, candidate), + awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 20 }, + }); + watcher.on('all', (event, file) => schedule(file, event)); + await new Promise((resolve, reject) => { + watcher!.once('ready', resolve); + watcher!.once('error', reject); + }); + watcherReadyAt = Date.now(); + watchedPaths = new Set(desired); + knownObservations = nextObservations; + knownBuildPaths = new Set(exact); + return; + } + /** 下一轮观察路径的去重集合。 */ + const next = new Set(desired); + /** 已不在最新构建图中的路径。 */ + const removed = [...watchedPaths].filter(candidate => !next.has(candidate)); + /** 需在发布成功前完成 ready 的新路径。 */ + const added = desired.filter(candidate => !watchedPaths.has(candidate)); + /** pending 文件不会在出现前进入 getWatched 的直属文件快照。 */ + const readiness = added.filter(candidate => nextObservations.get(candidate)?.pending !== true); + if (added.length > 0) { + watcher.add(added); + await waitUntilWatched(readiness); + } + /** 先扩张再收缩可保证任一失败时物理 watcher 至少是 last-good 的超集。 */ + if (removed.length > 0) + await watcher.unwatch(removed); + /** 所有物理操作成功后一次提交三份相互一致的逻辑状态。 */ + watchedPaths = next; + knownObservations = nextObservations; + knownBuildPaths = new Set(exact); + }; + + /** 使用全新 Kernel environment 执行一个完整构建轮次。 */ + const runRound = async (_changes: readonly string[]): Promise => { + /** 本轮独占的 capability 和中间目录环境。 */ + const environment = await createKernelBuildEnvironment(input.projectRoot); + /** 本轮最终的安全 BuildReport。 */ + let result: BuildReport; + /** 配置成功进入 BuildSession 后产生的报告与 watch 快照。 */ + let sessionResult: KernelBuildSessionResult | undefined; + try { + try { + /** 每轮 fresh evaluate 后的完整 Kernel 配置。 */ + const config = await input.loadConfig(environment); + outputDirectory = config.outDirectory; + sessionResult = await runKernelBuildSession({ + config, + frameworkVersion: input.frameworkVersion, + ...(selection === undefined ? {} : { selection }), + commit, + environment, + }); + result = sessionResult.report; + } catch (error) { + /** 已知配置异常中允许继续 watch 的稳定诊断。 */ + const diagnostics = error && typeof error === 'object' && 'diagnostics' in error + ? Reflect.get(error, 'diagnostics') + : undefined; + result = await failureReport(input, environment, Array.isArray(diagnostics) + ? diagnostics as readonly Diagnostic[] + : [{ + code: 'DEV_BUILD_FAILED', severity: 'error', phase: 'dev', message: 'Dev build failed.', + }]); + } + /** 失败轮也保留在失败前已登记的依赖快照。 */ + const watch = sessionResult?.watch ?? environment.watch.snapshot(); + return Object.freeze({ report: result, watch }); + } finally { + await disposeKernelBuildEnvironment(environment); + } + }; + + /** 执行一轮;初始化补偿轮不发布调用方无法订阅的事件。 */ + const round = async (changes: readonly string[], publish = true): Promise => { + /** 只有 Session resolve 后的公开 rebuild 才占用事件序号。 */ + const number = publish ? ++sequence : 0; + if (publish) + emit(Object.freeze({ type: 'build-start', sequence: number, changes: Object.freeze([...changes]) })); + /** 任意内部异常最终都必须映射为本 sequence 的一个完成报告。 */ + let result: BuildReport; + try { + /** 本轮内部报告和依赖快照。 */ + const roundResult = await runRound(changes); + result = roundResult.report; + /** closing 不再需要扩张 watcher,但在途轮仍必须完整发布并更新成功报告。 */ + if (!closing) { + try { + await updateWatcher(roundResult, result.success); + } catch { + result = await isolatedFailureReport(input, Object.freeze({ + code: 'DEV_WATCH_FAILED', severity: 'error', phase: 'dev', message: 'Dev watcher reconciliation failed.', + })); + } + } + } catch { + result = await isolatedFailureReport(input, Object.freeze({ + code: 'DEV_BUILD_FAILED', severity: 'error', phase: 'dev', message: 'Dev build failed.', + })); + } + if (result.success) + current = result; + if (publish && !closed) + emit(Object.freeze({ type: 'build-complete', sequence: number, changes: Object.freeze([...changes]), report: result })); + }; + + /** 串行排空所有已合并修改。 */ + const drain = async (publish = true): Promise => { + if (closed || closing) + return; + if (active !== undefined) { + await active; + return; + } + if (debounce !== undefined) { + clearTimeout(debounce); + debounce = undefined; + } + active = (async () => { + do { + pending = false; + /** 本轮的稳定变更路径快照。 */ + const changes = [...pendingChanges].sort(); + pendingChanges = new Set(); + await round(changes, publish); + } while (pending && !closed && !closing); + })().finally(() => { + active = undefined; + if (pending && !closed && !closing) + requestDrain(); + }); + await active; + }; + + /** 初始化抛出时用于无条件释放 poller/半建立 watcher。 */ + let initializationComplete = false; + try { + try { + /** 首轮构建前先监听工程根,避免构建期间的修改丢失。 */ + watcher = createWatcher([input.projectRoot], { + ignoreInitial: true, + followSymlinks: false, + /** 首轮同样使用可更新的解析输出根。 */ + ignored: (candidate: string) => ignoredPath(input.projectRoot, outputDirectory, candidate), + awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 20 }, + }); + watcher.on('all', (event, file) => schedule(file, event)); + await new Promise((resolve, reject) => { + watcher!.once('ready', resolve); + watcher!.once('error', reject); + }); + watcherReadyAt = Date.now(); + watchedPaths = new Set([input.projectRoot]); + /** 首轮在 watcher ready 后开始,使建立期修改可补偿。 */ + const first = await runRound([]); + current = first.report; + await updateWatcher(first, first.report.success); + /** 给 chokidar 一个稳定窗口交付首轮期间发生的写入。 */ + await new Promise(resolve => setTimeout(resolve, Math.max(0, 20 - (Date.now() - watcherReadyAt)))); + } catch (error) { + /** 失败 watcher 不得进入后续 reconciliation。 */ + if (watcher !== undefined) { + await watcher.close().catch(() => undefined); + watcher = undefined; + watchedPaths = new Set(); + } + /** watcher 初始化异常的失败报告也需要受管环境。 */ + const environment = await createKernelBuildEnvironment(input.projectRoot); + try { + current = await failureReport(input, environment, input.initialConfigError?.(error) ?? [{ + code: 'DEV_WATCH_FAILED', severity: 'error', phase: 'dev', message: 'Dev watcher setup failed.', + }]); + await updateWatcher({ report: current, watch: environment.watch.snapshot() }, false); + } finally { + await disposeKernelBuildEnvironment(environment); + } + } + initializationComplete = true; + } finally { + if (!initializationComplete) { + if (poller !== undefined) + clearInterval(poller); + await watcher?.close().catch(() => undefined); + watcher = undefined; + } + } + + initializing = false; + if (!current.success && input.configFile !== undefined + && await fs.stat(input.configFile).then(() => true).catch(() => false)) { + schedule(input.configFile); + } + if (pending && !closed && !closing) + await drain(false); + + /** 公开 Session 外壳只暴露报告、事件和幂等关闭。 */ + const session: DevSession = { + /** 返回最近一次可公开的报告。 */ + get current() { return current; }, + /** 登记一个事件订阅者并返回取消函数。 */ + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + /** 排空在途轮次、发布 closed、关闭 watcher 并撤销订阅。 */ + close() { + if (closeTask !== undefined) + return closeTask; + closeTask = (async () => { + /** cleanup errors 在终态发布后统一交给 close() 调用方。 */ + const failures: unknown[] = []; + closing = true; + pending = false; + pendingChanges = new Set(); + if (debounce !== undefined) { + clearTimeout(debounce); + debounce = undefined; + } + try { + try { + await active; + } catch (error) { + failures.push(error); + } + if (watcher !== undefined) { + try { + await watcher.close(); + } catch (error) { + failures.push(error); + } finally { + watcher = undefined; + } + } + } finally { + if (poller !== undefined) + clearInterval(poller); + closed = true; + emit(Object.freeze({ type: 'closed', sequence, report: current })); + listeners.clear(); + resolveClosed(); + } + if (failures.length > 0) + throw new AggregateError(failures, 'DevSession cleanup failed.'); + })(); + return closeTask; + }, + closed: closedPromise, + }; + return Object.freeze(session); +} diff --git a/packages/core/src/lifecycle/integration-sessions.ts b/packages/core/src/lifecycle/integration-sessions.ts new file mode 100644 index 0000000..e3e4680 --- /dev/null +++ b/packages/core/src/lifecycle/integration-sessions.ts @@ -0,0 +1,256 @@ +import type { + AcpluginExtension, + ExtensionIntegrationDescription, + ExtensionSession, + IntegrationCloseContext, + PlatformIntegrationDescription, + PlatformSession, +} from '../contracts/integrations.js'; +import type { JsonObject } from '../contracts/common.js'; +import type { BuildReport } from '../contracts/reports.js'; +import type { ResolvedKernelConfig, ResolvedPlatform } from '../config/resolver.js'; +import type { BuiltExtensionState, ExtensionConsumerPlan } from '../resources/extensions.js'; +import { dataObjectFields } from '../security/data-boundary.js'; +import { compareCodePoints } from '../security/path-policy.js'; +import { sanitizeStableText } from '../security/report-safety.js'; +import { DiagnosticRegistry } from '../services/diagnostics.js'; + +/** 已完成 setup 且必须逆序关闭的 Integration。 */ +export type InitializedIntegration = { + readonly kind: 'platform'; + readonly id: string; + readonly session: PlatformSession; +} | { + readonly kind: 'extension'; + readonly id: string; + readonly session: ExtensionSession; +}; + +/** 选中 Platform 与其 setup Session。 */ +export interface PlatformRuntime { + readonly resolved: ResolvedPlatform; + readonly description: PlatformIntegrationDescription; + /** 异构配置数组在 Core runtime 边界擦除各 Platform 的具体 payload union。 */ + readonly session: PlatformSession; +} + +/** Extension definition 与其 setup Session。 */ +export interface ExtensionRuntime { + readonly definition: AcpluginExtension; + readonly description: ExtensionIntegrationDescription; + readonly session: ExtensionSession; +} + +/** 每个 validated consumer plan 的显式 build 终态。 */ +export type ExtensionPlanBuildStatus = Readonly<{ + readonly plan: ExtensionConsumerPlan; + readonly status: 'skipped' | 'failed'; +}> | Readonly<{ + readonly plan: ExtensionConsumerPlan; + readonly status: 'built'; + readonly built: BuiltExtensionState; +}>; + +/** @returns 当前 Platform 是否依赖一个已失败 Extension 的 Built State。 */ +export function platformConsumesFailedExtension( + platform: string, + builds: readonly ExtensionPlanBuildStatus[], +): boolean { + return builds.some(build => build.status === 'failed' + && build.plan.consumers.some(consumer => consumer.platform.id === platform && consumer.contributor !== undefined)); +} + +/** 生命周期内部可稳定传递的首个失败摘要。 */ +export interface FailureSummary { + readonly code: string; + readonly phase: string; + readonly message: string; +} + +/** 生命周期失败优先级用于 cleanup 摘要,不依赖并发完成或诊断字典序。 */ +const FAILURE_PHASE_ORDER = Object.freeze([ + 'config', 'setup', 'discover', 'validate', 'compile', 'package', 'contribute', 'finalize', + 'materialize', 'platform-validate', 'compatibility', 'transaction', 'cleanup', 'dev', 'internal', +] as const); + +/** @returns 当前稳定诊断集合是否包含匹配的 error。 */ +export function hasMatchingError( + diagnostics: DiagnosticRegistry, + predicate: (diagnostic: BuildReport['diagnostics'][number]) => boolean, +): boolean { + return diagnostics.diagnostics.some(diagnostic => diagnostic.severity === 'error' && predicate(diagnostic)); +} + +/** @returns 当前 Platform 是否已经在自己的 validate/package 阶段失败。 */ +export function platformHasErrors(diagnostics: DiagnosticRegistry, platform: string): boolean { + return hasMatchingError(diagnostics, diagnostic => diagnostic.platform === platform); +} + +/** @returns 不属于单一 Integration 的工程级失败是否阻止全部 Package 消费。 */ +export function projectHasErrors(diagnostics: DiagnosticRegistry): boolean { + return hasMatchingError(diagnostics, diagnostic => diagnostic.platform === undefined && diagnostic.extension === undefined); +} + +/** @returns Platform 的不可变公开身份。 */ +export function platformDescription(platform: ResolvedPlatform): PlatformIntegrationDescription { + return Object.freeze({ + kind: 'platform', + id: platform.definition.id, + apiVersion: platform.definition.apiVersion, + ...(platform.definition.options === undefined ? {} : { options: platform.definition.options }), + ...(platform.definition.capabilities === undefined ? {} : { capabilities: platform.definition.capabilities }), + }); +} + +/** @returns Extension 的不可变公开身份。 */ +export function extensionDescription(extension: AcpluginExtension): ExtensionIntegrationDescription { + return Object.freeze({ + kind: 'extension', + id: extension.id, + apiVersion: extension.apiVersion, + ...(extension.options === undefined ? {} : { options: extension.options }), + resourceRoots: extension.resourceRoots, + }); +} + +/** 解析显式 Platform subset 并保持原配置顺序。 */ +export function selectPlatforms(config: ResolvedKernelConfig, selection: readonly string[] | undefined): readonly ResolvedPlatform[] { + if (selection === undefined) + return config.platforms; + if (!Array.isArray(selection) || selection.length === 0) + throw new TypeError('Platform selection must contain at least one configured Platform.'); + /** 选择输入在任何 Session factory 运行前拒绝重复与未知 ID。 */ + const requested = [...selection]; + if (requested.some(id => typeof id !== 'string') || new Set(requested).size !== requested.length) + throw new TypeError('Platform selection must contain unique Platform ids.'); + /** configured 用于在 setup 前拒绝未知 Platform。 */ + const configured = new Set(config.platforms.map(platform => platform.definition.id)); + /** unknown 按稳定键排序后只进入内部异常,不泄露配置对象。 */ + const unknown = requested.filter(id => !configured.has(id)); + if (unknown.length > 0) + throw new TypeError(`Platform selection contains an unconfigured id: ${unknown.sort(compareCodePoints)[0]}.`); + /** 返回顺序始终使用配置顺序而非 CLI 参数顺序。 */ + const selected = new Set(requested); + return Object.freeze(config.platforms.filter(platform => selected.has(platform.definition.id))); +} + +/** 验证 Platform Session 精确方法面。 */ +export function platformSession(value: unknown, id: string): PlatformSession { + /** fields 拒绝旧生命周期方法与未知行为面。 */ + const fields = dataObjectFields(value, new Set([ + 'validateComponent', 'createPackage', 'finalizePackage', 'validatePackage', 'createDistributions', 'close', + ]), `Platform "${id}" Session`); + for (const required of ['createPackage', 'finalizePackage', 'validatePackage']) { + if (typeof fields[required]?.value !== 'function') + throw new TypeError(`Platform "${id}" Session must provide ${required}().`); + } + for (const optional of ['validateComponent', 'createDistributions', 'close']) { + if (fields[optional] !== undefined && typeof fields[optional].value !== 'function') + throw new TypeError(`Platform "${id}" Session ${optional} must be a function.`); + } + return value as PlatformSession; +} + +/** 验证 Extension Session 精确方法面。 */ +export function extensionSession(value: unknown, id: string): ExtensionSession { + /** fields 固定 Extension v2 Session 的完整方法面。 */ + const fields = dataObjectFields(value, new Set(['discover', 'validate', 'build', 'contributors', 'close']), `Extension "${id}" Session`); + for (const required of ['discover', 'validate', 'build']) { + if (typeof fields[required]?.value !== 'function') + throw new TypeError(`Extension "${id}" Session must provide ${required}().`); + } + if (!Array.isArray(fields.contributors?.value)) + throw new TypeError(`Extension "${id}" Session must provide contributors.`); + if (fields.close !== undefined && typeof fields.close.value !== 'function') + throw new TypeError(`Extension "${id}" Session close must be a function.`); + return value as ExtensionSession; +} + +/** @returns 报告与 close 共用的首个错误摘要。 */ +export function firstFailure(diagnostics: DiagnosticRegistry): FailureSummary | undefined { + /** 同阶段使用 Registry 的稳定排序,跨阶段选择最早的实际生命周期失败。 */ + const failures = diagnostics.diagnostics.filter(diagnostic => diagnostic.severity === 'error'); + /** failure 在稳定诊断顺序相同时按固定 lifecycle phase 决定。 */ + const failure = failures.sort((left, right) => FAILURE_PHASE_ORDER.indexOf(left.phase) - FAILURE_PHASE_ORDER.indexOf(right.phase))[0]; + if (failure === undefined) + return undefined; + return Object.freeze({ code: failure.code, phase: failure.phase, message: failure.message }); +} + +/** 把未预期异常收敛为不包含第三方原始错误的稳定诊断。 */ +export function reportFailure( + diagnostics: DiagnosticRegistry, + phase: Parameters[0], + code: string, + message: string, + identity: Parameters[2] = {}, +): void { + diagnostics.report(phase, { code, severity: 'error', message: sanitizeStableText(message) }, identity); +} + +/** Platform 单阶段调用的显式成功/失败联合,避免异常跨阶段重新归类。 */ +export type PlatformStageResult = Readonly<{ ok: true; value: T }> | Readonly<{ ok: false }>; + +/** + * 在一个真实 Platform 阶段边界内收敛未知异常。 + * + * @param diagnostics 当前 BuildSession 诊断集合。 + * @param phase 报告中的精确阶段。 + * @param code 当前阶段的稳定错误码。 + * @param message 不包含原始异常的稳定摘要。 + * @param platform 当前 Platform ID。 + * @param action 只执行当前阶段工作的回调。 + * @returns 带显式判别字段的阶段结果。 + */ +export async function runPlatformStage( + diagnostics: DiagnosticRegistry, + phase: Parameters[0], + code: string, + message: string, + platform: string, + action: () => T | PromiseLike, +): Promise> { + try { + return Object.freeze({ ok: true as const, value: await action() }); + } catch { + reportFailure(diagnostics, phase, code, message, { owner: `platform:${platform}`, platform }); + return Object.freeze({ ok: false as const }); + } +} + +/** 对 initialized stack 逆序恰好关闭一次并保留首次业务失败优先级。 */ +export async function closeIntegrations( + initialized: InitializedIntegration[], + diagnostics: DiagnosticRegistry, + committed: boolean, +): Promise { + /** close 上下文在 cleanup 前固定,cleanup failure 不递归传给后续 close。 */ + const failure = firstFailure(diagnostics); + /** context 不暴露原始异常或物理路径。 */ + const context: IntegrationCloseContext = Object.freeze({ + outcome: failure === undefined ? 'success' : 'failed', + committed, + ...(failure === undefined ? {} : { failure }), + }); + /** 全部已初始化 Integration 即使前一个 close 失败也必须继续关闭。 */ + let failed = false; + for (const integration of initialized.reverse()) { + try { + await integration.session.close?.(context); + } catch { + failed = true; + reportFailure( + diagnostics, + 'cleanup', + integration.kind === 'platform' ? 'PLATFORM_CLOSE_FAILED' : 'EXTENSION_CLOSE_FAILED', + `${integration.kind === 'platform' ? 'Platform' : 'Extension'} "${integration.id}" close failed.`, + integration.kind === 'platform' + ? { owner: `platform:${integration.id}`, platform: integration.id } + : { owner: `extension:${integration.id}`, extension: integration.id }, + ); + } + } + initialized.splice(0); + if (failed) + throw new Error('Integration cleanup failed.'); +} diff --git a/packages/core/src/lifecycle/platform-pipeline.ts b/packages/core/src/lifecycle/platform-pipeline.ts new file mode 100644 index 0000000..ec61eb2 --- /dev/null +++ b/packages/core/src/lifecycle/platform-pipeline.ts @@ -0,0 +1,325 @@ +/** 单 Platform 的 Package、Contribution、Finalize 与 Candidate 流水线。 */ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { BuildMode, ConfigCommand } from '../contracts/config.js'; +import type { CanonicalProject } from '../contracts/components.js'; +import type { MergedPackageSnapshot, PackageUnitSnapshot } from '../contracts/packages.js'; +import { CompilerHost } from '../compiler/compiler-service.js'; +import { collectDistributionPackages } from '../package/distributions.js'; +import { withPackageCandidate, materializePackageUnits, validatePackageUnits } from '../package/candidate-materializer.js'; +import { + createBasePackage, + finalizePrimaryPackage, + mergePackageContributions, + type OwnedPackageContribution, +} from '../package/registry.js'; +import { + collectExtensionContributions, + type BuiltExtensionState, + type ExtensionConsumerPlan, +} from '../resources/extensions.js'; +import { + nodeRuntimeContribution, + platformSupportsNodeRuntime, + type BuiltNodeRuntime, +} from '../resources/runtime/provider.js'; +import { AssetRegistry } from '../services/assets.js'; +import { DiagnosticRegistry } from '../services/diagnostics.js'; +import { WorkDirectoryRegistry } from '../services/work-directories.js'; +import { + platformConsumesFailedExtension, + platformHasErrors, + runPlatformStage, + type ExtensionPlanBuildStatus, + type PlatformRuntime, +} from './integration-sessions.js'; + +/** 单 Platform 完成全部候选校验后的不可变结果。 */ +export interface PlatformPipelineResult { + readonly platform: PlatformRuntime; + readonly merged: MergedPackageSnapshot; + readonly units: readonly PackageUnitSnapshot[]; +} + +/** 向单个选中 Platform 授予 canonical auxiliary 和 Public AssetRef。 */ +function grantProjectAssets(project: CanonicalProject, platform: string, assets: AssetRegistry): void { + /** grantee 与 Platform Session 的 owner identity 完全一致。 */ + const grantee = `platform:${platform}`; + for (const skill of project.skills) { + for (const auxiliary of skill.auxiliaryFiles) + assets.grant('framework:canonical', grantee, auxiliary.asset); + } + for (const file of project.publicFiles) + assets.grant('framework:public', grantee, file.asset); +} + +/** Framework Resource 对当前 Platform 的 add-only Contributions。 */ +function frameworkContributions( + project: CanonicalProject, + runtime: BuiltNodeRuntime | undefined, + platform: PlatformRuntime['description'], + assets: AssetRegistry, +): readonly OwnedPackageContribution[] { + /** Public 与 Runtime 使用独立 owner,保持来源、冲突和 report 可审计。 */ + const contributions: OwnedPackageContribution[] = []; + if (project.publicFiles.length > 0) { + contributions.push(Object.freeze({ + owner: 'framework:public', + contribution: Object.freeze({ + assets: Object.freeze(project.publicFiles.map(file => Object.freeze({ path: file.path, asset: file.asset }))), + compatibility: Object.freeze([]), + }), + })); + } + if (project.runtime !== undefined) { + /** 支持 Platform 获得相同 GeneratedAssetRef 的显式继承 grant。 */ + if (runtime !== undefined && platformSupportsNodeRuntime(platform)) { + for (const entry of runtime.entries) { + assets.grant('framework:node-runtime', `platform:${platform.id}`, entry.main); + if (entry.licenses !== undefined) + assets.grant('framework:node-runtime', `platform:${platform.id}`, entry.licenses); + } + } + contributions.push(Object.freeze({ + owner: 'framework:node-runtime', + contribution: nodeRuntimeContribution(project.runtime, runtime, platform), + })); + } + return Object.freeze(contributions); +} + +/** 物化并调用 Platform validator,保持两种失败阶段互相独立。 */ +async function validatePlatformCandidate(options: { + readonly platform: PlatformRuntime; + readonly unit: PackageUnitSnapshot; + readonly command: ConfigCommand; + readonly mode: BuildMode; + readonly assets: AssetRegistry; + readonly workDirectories: WorkDirectoryRegistry; + readonly diagnostics: DiagnosticRegistry; +}): Promise { + /** id 同时绑定 candidate 临时目录、诊断 owner 和 Platform Session。 */ + const id = options.platform.description.id; + /** 每个平台的 candidate 只能位于其 Core-owned workDir。 */ + const temporaryParent = options.workDirectories.physicalRoot( + `platform:${id}`, + await options.workDirectories.directory(`platform:${id}`), + ); + /** 外层只捕获 candidate materialize、post-validate integrity 与 cleanup failure。 */ + const materialization = await runPlatformStage( + options.diagnostics, + 'materialize', + 'PACKAGE_CANDIDATE_MATERIALIZATION_FAILED', + `Platform "${id}" Package "${options.unit.id}" candidate materialization failed.`, + id, + async () => { + /** validatorSucceeded 让 validator failure 不必冒充 materialization exception。 */ + let validatorSucceeded = true; + await withPackageCandidate(options.unit, options.assets, async (candidate) => { + /** validation 只收敛 Platform callback,本地候选完整性仍交给外层。 */ + const validation = await runPlatformStage( + options.diagnostics, + 'platform-validate', + 'PLATFORM_VALIDATE_PACKAGE_FAILED', + `Platform "${id}" Package "${options.unit.id}" validation failed.`, + id, + () => options.platform.session.validatePackage(Object.freeze({ + command: options.command, + mode: options.mode, + candidate, + diagnostics: options.diagnostics.service('platform-validate', { owner: `platform:${id}`, platform: id }), + })), + ); + /** Platform 自己报告 error 而未 throw 时也必须阻止当前 Unit 成功。 */ + validatorSucceeded = validation.ok && !platformHasErrors(options.diagnostics, id); + }, temporaryParent); + return validatorSucceeded; + }, + ); + return materialization.ok && materialization.value && !platformHasErrors(options.diagnostics, id); +} + +/** 在独立临时根复核全部 Unit 的 aggregate materialization closure。 */ +export async function validateCompleteMaterialization( + units: readonly PackageUnitSnapshot[], + assets: AssetRegistry, + workRoot: string, +): Promise { + /** complete candidate 使用当前 BuildSession workRoot 下的独立临时根。 */ + const root = await fs.mkdtemp(path.join(workRoot, 'complete-')); + try { + /** materialized 索引用于复核 aggregate Unit closure。 */ + const materialized = await materializePackageUnits(root, units, assets); + await validatePackageUnits(root, units, materialized); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +} + +/** + * 执行单个 Platform 的固定 Package 流水线。 + * + * 调用方仍唯一拥有跨 Platform 并发、Compatibility 汇总和事务边界。 + */ +export async function runPlatformPipeline(options: { + readonly platform: PlatformRuntime; + readonly project: CanonicalProject; + readonly runtime: BuiltNodeRuntime | undefined; + readonly plans: readonly ExtensionConsumerPlan[]; + readonly built: readonly BuiltExtensionState[]; + readonly planBuilds: readonly ExtensionPlanBuildStatus[]; + readonly command: ConfigCommand; + readonly mode: BuildMode; + readonly compiler: CompilerHost; + readonly assets: AssetRegistry; + readonly workDirectories: WorkDirectoryRegistry; + readonly diagnostics: DiagnosticRegistry; +}): Promise { + /** id 同时绑定 Context owner、诊断和最终 Package namespace。 */ + const id = options.platform.description.id; + /** 工程级错误由调用方统一阻断;本函数只隔离当前 Platform 和依赖 Extension。 */ + if (platformHasErrors(options.diagnostics, id) + || platformConsumesFailedExtension(id, options.planBuilds)) + return undefined; + + /** createPackage 与 base snapshot validation 属于 package 阶段。 */ + const created = await runPlatformStage( + options.diagnostics, + 'package', + 'PLATFORM_CREATE_PACKAGE_FAILED', + `Platform "${id}" createPackage failed.`, + id, + async () => { + grantProjectAssets(options.project, id, options.assets); + return createBasePackage(id, await options.platform.session.createPackage(Object.freeze({ + command: options.command, + mode: options.mode, + project: options.project, + compiler: await options.compiler.service(`platform:${id}`), + assets: options.assets.service(`platform:${id}`), + diagnostics: options.diagnostics.service('package', { owner: `platform:${id}`, platform: id }), + })), options.assets); + }, + ); + if (!created.ok || platformHasErrors(options.diagnostics, id)) + return undefined; + + /** Contributor collection、Framework contribution 与集中 merge 共用 contribute 边界。 */ + const contributed = await runPlatformStage( + options.diagnostics, + 'contribute', + 'PLATFORM_CONTRIBUTION_FAILED', + `Platform "${id}" Package contribution failed.`, + id, + async () => { + /** Extension Contribution 全部读取 created.value 的同一对象身份。 */ + const extensionContributions = await collectExtensionContributions({ + platform: options.platform.description, + base: created.value, + project: options.project, + command: options.command, + mode: options.mode, + plans: options.plans, + built: options.built, + assets: options.assets, + diagnostics: options.diagnostics, + }); + return mergePackageContributions(id, created.value, [ + ...frameworkContributions(options.project, options.runtime, options.platform.description, options.assets), + ...extensionContributions, + ], options.assets); + }, + ); + if (!contributed.ok || platformHasErrors(options.diagnostics, id)) + return undefined; + + /** Platform finalization 只确定主 Package 身份并追加 Platform Asset。 */ + const finalized = await runPlatformStage( + options.diagnostics, + 'finalize', + 'PLATFORM_FINALIZE_PACKAGE_FAILED', + `Platform "${id}" primary Package finalization failed.`, + id, + async () => { + /** finalization 是唯一得到 Component provenance asset capability 的 Platform callback。 */ + const scope = options.assets.componentFinalizationScope(id, `platform:${id}`, contributed.value.components); + let input; + try { + input = await options.platform.session.finalizePackage(Object.freeze({ + command: options.command, + mode: options.mode, + project: options.project, + package: contributed.value, + compiler: await options.compiler.service(`platform:${id}`), + assets: scope.service, + diagnostics: options.diagnostics.service('finalize', { owner: `platform:${id}`, platform: id }), + })); + } finally { + /** callback 返回/抛出后立即撤销 service,阻止延迟 provenance 签发。 */ + scope.close(); + } + /** Platform 已报告的可预期输入错误不能继续进入 Core Document/Asset materialization。 */ + if (platformHasErrors(options.diagnostics, id)) + return undefined; + return finalizePrimaryPackage(id, options.platform.resolved.definition.deliveryType, contributed.value, input, options.assets); + }, + ); + if (!finalized.ok || finalized.value === undefined || platformHasErrors(options.diagnostics, id)) + return undefined; + /** Narrow once before later Distribution callback closures. */ + const primary = finalized.value; + if (!await validatePlatformCandidate({ + platform: options.platform, + unit: primary, + command: options.command, + mode: options.mode, + assets: options.assets, + workDirectories: options.workDirectories, + diagnostics: options.diagnostics, + })) + return undefined; + + /** Distribution creation 是从已验证 primary 派生的 finalization 子阶段。 */ + const distributions = await runPlatformStage( + options.diagnostics, + 'finalize', + 'PLATFORM_FINALIZE_PACKAGE_FAILED', + `Platform "${id}" Distribution finalization failed.`, + id, + async () => options.platform.session.createDistributions === undefined + ? Object.freeze([]) + : collectDistributionPackages({ + platform: id, + primary, + assets: options.assets, + /** create callback 不暴露 Registry,只委托当前 Platform Session。 */ + create: scopedAssets => Promise.resolve(options.platform.session.createDistributions!(Object.freeze({ + command: options.command, + mode: options.mode, + project: options.project, + primary, + assets: scopedAssets, + diagnostics: options.diagnostics.service('finalize', { owner: `platform:${id}`, platform: id }), + }))), + }), + ); + if (!distributions.ok || platformHasErrors(options.diagnostics, id)) + return undefined; + for (const distribution of distributions.value) { + if (!await validatePlatformCandidate({ + platform: options.platform, + unit: distribution, + command: options.command, + mode: options.mode, + assets: options.assets, + workDirectories: options.workDirectories, + diagnostics: options.diagnostics, + })) + return undefined; + } + return Object.freeze({ + platform: options.platform, + merged: contributed.value, + units: Object.freeze([primary, ...distributions.value]), + }); +} diff --git a/packages/core/src/output/lock.ts b/packages/core/src/output/lock.ts new file mode 100644 index 0000000..87bc9a8 --- /dev/null +++ b/packages/core/src/output/lock.ts @@ -0,0 +1,429 @@ +/** 受管输出的跨进程锁协议。 */ +import { randomUUID } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import type { BigIntStats } from 'node:fs'; +import type { FileHandle } from 'node:fs/promises'; +import path from 'node:path'; +import { compareCodePoints } from '../security/path-policy.js'; + +/** 独占锁完整发布后才允许出现的 owner record。 */ +interface ManagedOutputLockRecord { + readonly schemaVersion: 3; + readonly pid: number; + readonly token: string; +} +/** 读取锁时同时保留精确字节,供无 CAS 删除前复核。 */ +interface ManagedOutputLockObservation { + readonly bytes: string; + readonly metadata: ManagedOutputLockMetadata; + readonly record?: ManagedOutputLockRecord; +} + +/** 路径观察的稳定 inode 与内容 metadata。 */ +interface ManagedOutputLockMetadata { + readonly device: bigint; + readonly inode: bigint; + readonly mode: bigint; + readonly size: bigint; + readonly modified: bigint; + readonly changed: bigint; + readonly created: bigint; +} + +/** 锁路径元数据操作使用的唯一、可精确清理 guard。 */ +interface ManagedOutputLockGuard { + readonly path: string; + readonly pid: number; + readonly token: string; +} + +/** 当前进程仍实际持有的 token;清理失败后的同 PID record 不再视为活锁。 */ +const ACTIVE_LOCK_TOKENS = new Set(); + +/** 当前进程正在发布或持有的 lock-metadata guard token。 */ +const ACTIVE_LOCK_GUARD_TOKENS = new Set(); + +/** randomUUID 的稳定小写文本形态,避免任意 lock 内容进入 owner 判断。 */ +const LOCK_TOKEN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; + +/** 旧 create→write malformed lock 在隔离前必须保持不变的有界观察窗口。 */ +const LEGACY_LOCK_STABILITY_DELAY_MS = 25; + +/** 关闭当前调用独占的 handle;瞬时失败时再尝试一次,避免泄漏描述符。 */ +async function closeOwnedFile(handle: FileHandle): Promise { + try { + await handle.close(); + } catch (firstError) { + try { + await handle.close(); + } catch { + throw firstError; + } + } +} + +/** 删除永不复用或由 metadata guard 保护的自有路径;瞬时失败时安全重试。 */ +async function removeOwnedPath(file: string): Promise { + try { + await fs.rm(file, { force: true }); + } catch (firstError) { + try { + await fs.rm(file, { force: true }); + } catch { + throw firstError; + } + } +} + +/** 从 bigint lstat 提取锁恢复需要比较的稳定 metadata。 */ +function managedOutputLockMetadata(stat: BigIntStats): ManagedOutputLockMetadata { + return Object.freeze({ + device: stat.dev, + inode: stat.ino, + mode: stat.mode, + size: stat.size, + modified: stat.mtimeNs, + changed: stat.ctimeNs, + created: stat.birthtimeNs, + }); +} + +/** @returns 两次路径观察是否仍指向同一份未变化内容。 */ +function sameManagedOutputLockMetadata( + left: ManagedOutputLockMetadata, + right: ManagedOutputLockMetadata, +): boolean { + return left.device === right.device && left.inode === right.inode && left.mode === right.mode + && left.size === right.size && left.modified === right.modified && left.changed === right.changed + && left.created === right.created; +} + +/** @returns rename 后的路径是否仍是首次观察的同一个 inode。 */ +function sameManagedOutputLockInode( + left: ManagedOutputLockMetadata, + right: ManagedOutputLockMetadata, +): boolean { + return left.device === right.device && left.inode === right.inode && left.created === right.created; +} + +/** 读取一个完整锁记录;旧版或截断内容作为可隔离的 malformed observation。 */ +async function readManagedOutputLock(file: string): Promise { + /** 锁绝不能借助 symlink 或特殊文件影响同级输出。 */ + const pathBefore = await fs.lstat(file, { bigint: true }); + if (pathBefore.isSymbolicLink() || !pathBefore.isFile()) + throw new Error('Managed output lock must be a regular file.'); + /** FileHandle 把 metadata 与字节绑定到同一 inode,避免 path read 的替换竞态。 */ + const handle = await fs.open(file, 'r'); + /** handle 读取的精确锁字节。 */ + let bytes: string; + /** handle 读取完成后的稳定 metadata。 */ + let metadata: ManagedOutputLockMetadata; + try { + /** open 前后的 inode 必须仍与首次 lstat 一致,且不能变成特殊文件。 */ + const before = await handle.stat({ bigint: true }); + if (!before.isFile() + || !sameManagedOutputLockMetadata(managedOutputLockMetadata(pathBefore), managedOutputLockMetadata(before))) + throw new Error('Managed output lock changed while it was being observed.'); + /** 精确原始字节用于隔离时确认没有搬走另一个 writer 的新记录。 */ + bytes = await handle.readFile({ encoding: 'utf8' }); + /** handle 与当前路径在读取后必须仍指向同一份未变化内容。 */ + const after = await handle.stat({ bigint: true }); + /** 当前路径的最终 metadata 用于确认没有 replacement。 */ + const pathAfter = await fs.lstat(file, { bigint: true }); + metadata = managedOutputLockMetadata(after); + if (!sameManagedOutputLockMetadata(managedOutputLockMetadata(before), metadata) + || !sameManagedOutputLockMetadata(metadata, managedOutputLockMetadata(pathAfter))) + throw new Error('Managed output lock changed while it was being observed.'); + } finally { + await closeOwnedFile(handle); + } + try { + /** 未验证 JSON 只在当前函数局部存在。 */ + const value: unknown = JSON.parse(bytes); + if (typeof value !== 'object' || value === null || Array.isArray(value)) + return Object.freeze({ bytes, metadata }); + /** schema 3 只允许 pid/token/schemaVersion 三个固定字段。 */ + const record = value as Record; + if (Object.keys(record).sort(compareCodePoints).join(',') !== 'pid,schemaVersion,token' + || record.schemaVersion !== 3 || !Number.isSafeInteger(record.pid) || Number(record.pid) <= 0 + || typeof record.token !== 'string' || !LOCK_TOKEN.test(record.token)) { + return Object.freeze({ bytes, metadata }); + } + return Object.freeze({ + bytes, + metadata, + record: Object.freeze({ schemaVersion: 3, pid: Number(record.pid), token: record.token }), + }); + } catch { + return Object.freeze({ bytes, metadata }); + } +} + +/** @returns 已验证 PID 是否仍对应一个可见进程。 */ +function processIsAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + /** EPERM 同样证明进程存在,只是当前调用者无权发送信号。 */ + return (error as NodeJS.ErrnoException).code !== 'ESRCH'; + } +} + +/** 从 lock sibling 名称读取唯一 guard 的 PID、token 和发布状态。 */ +function parseManagedOutputLockGuard( + lockFile: string, + name: string, +): (ManagedOutputLockGuard & { readonly draft: boolean }) | undefined { + /** guard 名称只匹配当前 outDir 的精确 lock basename。 */ + const prefix = `${path.basename(lockFile)}.guard.`; + if (!name.startsWith(prefix)) + return undefined; + /** writing 后缀表示完整 record 尚未原子发布。 */ + const draft = name.endsWith('.writing'); + /** 剩余部分固定为 pid.token,UUID 不包含点号。 */ + const identity = name.slice(prefix.length, draft ? -'.writing'.length : undefined); + /** 第一个点号稳定分隔十进制 PID 与 UUID token。 */ + const separator = identity.indexOf('.'); + if (separator <= 0) + return undefined; + /** PID 来自名称即可在部分 draft 上判断 owner 是否仍存活。 */ + const pidText = identity.slice(0, separator); + /** token 使路径永不被另一个正常调用复用。 */ + const token = identity.slice(separator + 1); + /** 数值 PID 必须保持在 JavaScript 精确整数范围内。 */ + const pid = Number(pidText); + if (!/^[1-9][0-9]*$/u.test(pidText) || !Number.isSafeInteger(pid) || pid <= 0 || !LOCK_TOKEN.test(token)) + return undefined; + /** 绝对 guard 路径只由受管 lock 同级名称组合。 */ + const guardPath = path.join(path.dirname(lockFile), name); + return Object.freeze({ path: guardPath, pid, token, draft }); +} + +/** 发布一个唯一 guard;并发调用互不覆盖,进程崩溃后路径仍可精确回收。 */ +async function publishManagedOutputLockGuard(lockFile: string): Promise { + /** 名称中的 PID/token 允许在 draft 尚不完整时判断 owner。 */ + const pid = process.pid; + /** 每个 guard 路径在所有正常调用间永久唯一。 */ + const token = randomUUID(); + /** 最终 guard record 只在完整写入后通过 hard link 出现。 */ + const finalPath = `${lockFile}.guard.${pid}.${token}`; + /** 同级唯一 draft 不参与互斥,owner identity 已在文件名中。 */ + const draftPath = `${finalPath}.writing`; + ACTIVE_LOCK_GUARD_TOKENS.add(token); + try { + /** draft 从创建起保持私有普通文件。 */ + const handle = await fs.open(draftPath, 'wx', 0o600); + try { + await handle.writeFile(`${JSON.stringify({ schemaVersion: 3, pid, token })}\n`); + await handle.sync(); + } finally { + await closeOwnedFile(handle); + } + /** 唯一 final path 仍使用 no-replace 发布,避免任何路径覆盖。 */ + await fs.link(draftPath, finalPath); + await removeOwnedPath(draftPath); + return Object.freeze({ path: finalPath, pid, token }); + } catch (error) { + /** 发布失败只清理当前唯一 identity 的两个路径。 */ + await removeOwnedPath(finalPath).catch(() => undefined); + await removeOwnedPath(draftPath).catch(() => undefined); + ACTIVE_LOCK_GUARD_TOKENS.delete(token); + throw error; + } +} + +/** @returns guard 是否仍由一个实际存活的调用持有或发布。 */ +function managedOutputLockGuardIsLive(guard: ManagedOutputLockGuard): boolean { + if (guard.pid === process.pid) + return ACTIVE_LOCK_GUARD_TOKENS.has(guard.token); + return processIsAlive(guard.pid); +} + +/** 精确释放当前唯一 guard,失败残留由下一次扫描按同一路径回收。 */ +async function releaseManagedOutputLockGuard(guard: ManagedOutputLockGuard): Promise { + try { + await removeOwnedPath(guard.path); + } finally { + ACTIVE_LOCK_GUARD_TOKENS.delete(guard.token); + } +} + +/** + * 获取 lock path 元数据互斥权。 + * + * 每个竞争者先发布自己的唯一 intent,再扫描所有 intent;晚到者一定能看到仍在 + * 临界区内的早到者。竞争同时发生时允许双方短暂退避,但绝不允许双方进入。 + */ +async function acquireManagedOutputLockGuard(lockFile: string): Promise { + for (let attempt = 0; attempt < 8; attempt += 1) { + /** 当前 attempt 使用全新 identity,旧 attempt 路径不会被复用。 */ + const own = await publishManagedOutputLockGuard(lockFile); + /** 是否存在另一个仍在发布或持有的 guard。 */ + let conflict = false; + try { + /** 目录快照足以建立互斥:任何快照后的新 guard 都必须看到 own。 */ + const names = (await fs.readdir(path.dirname(lockFile))).sort(compareCodePoints); + for (const name of names) { + /** 非当前 lock 的普通 sibling 与 transaction helper 不参与 guard 协议。 */ + const candidate = parseManagedOutputLockGuard(lockFile, name); + if (candidate === undefined || (!candidate.draft && candidate.path === own.path)) + continue; + if (managedOutputLockGuardIsLive(candidate)) { + conflict = true; + continue; + } + /** 唯一 PID/token 路径永不复用,因此 stale cleanup 不会删除新 guard。 */ + await removeOwnedPath(candidate.path); + } + if (!conflict) + return own; + } catch (error) { + await releaseManagedOutputLockGuard(own).catch(() => undefined); + throw error; + } + await releaseManagedOutputLockGuard(own); + /** 小幅有界退避避免两个同时到达的调用持续同步冲突。 */ + await new Promise(resolve => setTimeout(resolve, attempt + 1)); + } + throw new Error('Managed output lock metadata is locked by another process.'); +} + +/** 确认 guard 内的 lock record 与首次观察完全一致。 */ +async function assertManagedOutputLockUnchanged( + file: string, + observation: ManagedOutputLockObservation, +): Promise { + if (observation.record === undefined) { + /** 旧 writer 可能先创建空文件再写 record,给其一个固定且有界的完成窗口。 */ + await new Promise(resolve => setTimeout(resolve, LEGACY_LOCK_STABILITY_DELAY_MS)); + } + /** 第二次完整读取是 malformed/stale recovery 的有界 unchanged-record check。 */ + const current = await readManagedOutputLock(file); + if (current.bytes !== observation.bytes + || !sameManagedOutputLockMetadata(current.metadata, observation.metadata)) + throw new Error('Managed output lock changed during stale recovery.'); +} + +/** + * 原子隔离当前精确观察到的 stale/malformed lock。 + * + * rename 后只删除字节仍匹配的 inode;若竞争者替换了记录则尽力恢复并失败关闭。 + */ +async function quarantineManagedOutputLock( + file: string, + observation: ManagedOutputLockObservation, +): Promise { + /** rename 前在 metadata guard 内完成第二次完整 unchanged-record check。 */ + await assertManagedOutputLockUnchanged(file, observation); + /** 唯一同级 quarantine 避免并发 cleaner 覆盖彼此。 */ + const quarantine = `${file}.${randomUUID()}.stale`; + try { + await fs.rename(file, quarantine); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') + return; + throw error; + } + try { + /** random token 使合法 writer replacement 不可能与旧 observation 字节相同。 */ + const moved = await readManagedOutputLock(quarantine); + if (moved.bytes !== observation.bytes + || !sameManagedOutputLockInode(moved.metadata, observation.metadata)) { + try { + await fs.link(quarantine, file); + } catch { + /** 另一个 writer 已占用最终 lock 时不能覆盖它。 */ + } + throw new Error('Managed output lock changed during stale recovery.'); + } + } finally { + await removeOwnedPath(quarantine); + } +} + +/** 把完整 owner record 通过 hard-link no-replace 原子发布为最终锁。 */ +async function publishManagedOutputLock(file: string): Promise { + /** token 同时区分同 PID 的当前 holder 与 cleanup 失败残留。 */ + const token = randomUUID(); + /** 同目录唯一草稿保证 hard-link 发布不跨文件系统。 */ + const draft = `${file}.${token}.writing`; + /** 草稿从创建起就是私有普通文件。 */ + const handle = await fs.open(draft, 'wx', 0o600); + try { + try { + await handle.writeFile(`${JSON.stringify({ schemaVersion: 3, pid: process.pid, token })}\n`); + await handle.sync(); + } finally { + await closeOwnedFile(handle); + } + /** final path 要么不存在并完整出现,要么保持既有 writer 不变。 */ + await fs.link(draft, file); + } finally { + await removeOwnedPath(draft); + } + return token; +} + +/** 只释放仍由当前 holder token 标识的最终锁。 */ +async function removeManagedOutputLockRecord(file: string, token: string): Promise { + /** 删除前重新读取最终锁,避免移除另一个 writer 已替换的记录。 */ + const observation = await readManagedOutputLock(file); + if (observation.record?.pid !== process.pid || observation.record.token !== token) + throw new Error('Managed output lock ownership changed before release.'); + await removeOwnedPath(file); +} + +/** 创建独占锁;完整 stale/malformed 状态隔离后允许有限重试。 */ +export async function acquireManagedOutputLock(lockPath: string): Promise { + for (let attempt = 0; attempt < 4; attempt += 1) { + /** 所有 fixed lock path 读取、发布和恢复都在唯一 guard 内串行化。 */ + const guard = await acquireManagedOutputLockGuard(lockPath); + try { + try { + /** hard-link publication 是多个 acplugin 进程间的原子事务互斥点。 */ + const token = await publishManagedOutputLock(lockPath); + ACTIVE_LOCK_TOKENS.add(token); + return token; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') + throw error; + } + try { + /** final lock 从出现起就应当是完整 schema 3 record。 */ + const observation = await readManagedOutputLock(lockPath); + /** 当前进程仍登记的 token 和任何其他存活 PID 都是活 writer。 */ + const live = observation.record !== undefined + && ((observation.record.pid === process.pid && ACTIVE_LOCK_TOKENS.has(observation.record.token)) + || (observation.record.pid !== process.pid && processIsAlive(observation.record.pid))); + if (live) + throw new Error(`Managed output is locked by process ${observation.record!.pid}.`); + /** 新协议不会发布 malformed record;旧 create→write 残留经复核后隔离。 */ + await quarantineManagedOutputLock(lockPath, observation); + } catch (lockError) { + if ((lockError as NodeJS.ErrnoException).code === 'ENOENT') + continue; + throw new Error(`Managed output is locked. ${String(lockError)}`, { cause: lockError }); + } + } finally { + await releaseManagedOutputLockGuard(guard).catch(() => undefined); + } + } + throw new Error('Managed output lock could not be acquired after stale recovery.'); +} + +/** 在 metadata guard 内释放当前 holder,并撤销当前进程的 active token。 */ +export async function releaseManagedOutputLock(lockPath: string, token: string): Promise { + try { + /** release 必须与 stale recovery/new publication 使用同一 metadata guard。 */ + const guard = await acquireManagedOutputLockGuard(lockPath); + try { + await removeManagedOutputLockRecord(lockPath, token); + } finally { + await releaseManagedOutputLockGuard(guard); + } + } finally { + /** 清理失败后的同 PID record 在下一轮应被识别为 stale。 */ + ACTIVE_LOCK_TOKENS.delete(token); + } +} diff --git a/packages/core/src/output/recovery.ts b/packages/core/src/output/recovery.ts new file mode 100644 index 0000000..b44eab8 --- /dev/null +++ b/packages/core/src/output/recovery.ts @@ -0,0 +1,80 @@ +/** 受管输出在持锁状态下的崩溃恢复。 */ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { validatePhysicalEntry } from '../security/path-policy.js'; +import { + exists, + readTransactionMarker, + type ManagedOutputPaths, +} from './transaction-files.js'; + +/** + * 恢复或清理上一次事务留下的确定状态。 + * + * 调用方必须已经持有 `paths.lock`,本函数不负责锁生命周期。 + */ +export async function recoverManagedOutput( + paths: ManagedOutputPaths, + projectRoot: string, +): Promise { + /** pending 与 committed marker 共同消除 swap 后崩溃的恢复歧义。 */ + const pendingRecord = await readTransactionMarker(paths.transaction, paths.base); + /** committed marker 必须与 pending record 描述同一个事务。 */ + const committedRecord = await readTransactionMarker(paths.committed, paths.base); + if (pendingRecord !== undefined && committedRecord !== undefined + && JSON.stringify(pendingRecord) !== JSON.stringify(committedRecord)) { + throw new Error('Managed output transaction markers do not match.'); + } + /** 上次事务遗留 backup 的普通目录边界。 */ + const hasBackup = await exists(paths.backup); + if (hasBackup) { + /** backup 只能是同级普通目录,绝不能恢复一个符号链接。 */ + const backupStat = await fs.lstat(paths.backup); + if (backupStat.isSymbolicLink() || !backupStat.isDirectory()) + throw new Error('Managed output backup must be a regular directory.'); + } + if (committedRecord !== undefined) { + /** cleanup 已完成的事务保留新输出;异常缺失时回退到仍完整的旧 backup。 */ + if (!await exists(paths.resolved) && hasBackup) + await fs.rename(paths.backup, paths.resolved); + else if (hasBackup) + await fs.rm(paths.backup, { recursive: true, force: true }); + } else if (pendingRecord !== undefined) { + /** 未提交事务必须恢复调用前状态。 */ + if (pendingRecord.hadOutput) { + if (hasBackup) { + if (await exists(paths.resolved)) + await fs.rm(paths.resolved, { recursive: true, force: true }); + await fs.rename(paths.backup, paths.resolved); + } else if (!await exists(paths.resolved)) { + throw new Error('Managed output rollback record lost both output and backup.'); + } + } else { + if (hasBackup) + throw new Error('Managed output rollback record has an unexpected backup.'); + if (await exists(paths.resolved)) + await fs.rm(paths.resolved, { recursive: true, force: true }); + } + } else if (hasBackup) { + /** 无 marker 的 backup 只可能来自已提交事务的最后清理窗口。 */ + if (!await exists(paths.resolved)) + await fs.rename(paths.backup, paths.resolved); + else + await fs.rm(paths.backup, { recursive: true, force: true }); + } + /** recovery 后的正式输出必须仍位于工程内且无 symlink 祖先。 */ + if (await exists(paths.resolved)) + await validatePhysicalEntry(path.resolve(projectRoot), paths.resolved, 'directory'); + if (pendingRecord !== undefined) + await fs.rm(paths.transaction, { force: true }); + if (committedRecord !== undefined) + await fs.rm(paths.committed, { force: true }); + /** 未原子发布的 marker 草稿没有恢复权威,统一在锁内清理。 */ + await fs.rm(paths.transactionWriting, { force: true }); + await fs.rm(paths.committedWriting, { force: true }); + /** 只清理当前 outDir 专属前缀的旧 stage。 */ + for (const entry of await fs.readdir(paths.parent, { withFileTypes: true })) { + if (entry.name.startsWith(paths.stagePrefix)) + await fs.rm(path.join(paths.parent, entry.name), { recursive: true, force: true }); + } +} diff --git a/packages/core/src/output/transaction-files.ts b/packages/core/src/output/transaction-files.ts new file mode 100644 index 0000000..1f84bf1 --- /dev/null +++ b/packages/core/src/output/transaction-files.ts @@ -0,0 +1,405 @@ +/** 受管输出的稳定路径、marker 与 preserved Platform 文件协议。 */ +import { createHash } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { PackageUnitSnapshot } from '../contracts/packages.js'; +import { + compareCodePoints, + isInsidePath, + sourceCollisionKey, +} from '../security/path-policy.js'; +import { scanPhysicalTree } from '../package/candidate-materializer.js'; + +/** 完整构建替换所有输出;显式 subset 只替换所选 Platform。 */ +export type ManagedOutputScope = { + readonly type: 'full'; +} | { + readonly type: 'subset'; + readonly platforms: readonly string[]; +}; + +/** 既有未选 Platform 中一个普通文件的稳定快照。 */ +interface PreservedFile { + readonly path: string; + readonly bytes: Uint8Array; + readonly mode: 0o644 | 0o755; + readonly size: number; + readonly sha256: string; +} +/** 一个未选 Platform 的完整旧输出快照。 */ +export interface PreservedPlatform { + readonly id: string; + readonly directories: readonly string[]; + readonly files: readonly PreservedFile[]; +} + +/** 崩溃恢复所需的最小 rollback record。 */ +export interface TransactionRecord { + readonly schemaVersion: 2; + readonly outDir: string; + readonly scope: ManagedOutputScope['type']; + readonly hadOutput: boolean; +} + +/** 未完成 marker 只允许存在于这个固定、可恢复的临时后缀。 */ +export const MARKER_WRITING_SUFFIX = '.writing'; + +/** Platform 和 Unit ID 使用的稳定 lowercase-kebab 规则。 */ +const STABLE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + +/** @returns 路径是否存在;ENOENT 以外错误仍按不存在处理到后续操作。 */ +export async function exists(candidate: string): Promise { + try { + await fs.access(candidate); + return true; + } catch { + return false; + } +} + +/** + * 持久写入一个不含物理路径的事务 marker。 + * + * @param file 同一受管输出专属的 marker 路径。 + * @param record 当前事务的稳定恢复信息。 + */ +export async function writeTransactionMarker(file: string, record: TransactionRecord): Promise { + /** 临时普通文件先完整落盘,最终 marker 永远不会暴露部分 JSON。 */ + const writing = `${file}${MARKER_WRITING_SUFFIX}`; + /** `wx` 防止遗留或并发状态被当前事务静默覆盖。 */ + const handle = await fs.open(writing, 'wx', 0o600); + try { + await handle.writeFile(`${JSON.stringify(record)}\n`); + /** 临时 marker 内容先落盘,随后才允许原子发布最终目录项。 */ + await handle.sync(); + } finally { + await handle.close(); + } + try { + /** 同目录 hard link 原子发布且拒绝覆盖任何既有最终 marker。 */ + await fs.link(writing, file); + } finally { + /** 发布前失败或发布后崩溃遗留的临时链接都不参与恢复判断。 */ + await fs.rm(writing, { force: true }); + } +} + +/** + * 读取并验证一个受管事务 marker。 + * + * @param file 当前输出专属 marker 路径。 + * @param expectedOutDir 当前受管输出 basename。 + * @returns marker 不存在时返回 undefined。 + */ +export async function readTransactionMarker(file: string, expectedOutDir: string): Promise { + /** marker 缺失是正常恢复状态。 */ + const stat = await fs.lstat(file).catch(() => undefined); + if (stat === undefined) + return undefined; + if (stat.isSymbolicLink() || !stat.isFile()) + throw new Error('Managed output transaction marker must be a regular file.'); + /** 未验证 JSON 只能用于恢复状态判断,不能提供任意路径。 */ + const value: unknown = JSON.parse(await fs.readFile(file, 'utf8')); + if (typeof value !== 'object' || value === null || Array.isArray(value)) + throw new Error('Managed output transaction marker is invalid.'); + /** marker 只允许固定恢复字段。 */ + const record = value as Record; + if (Object.keys(record).sort(compareCodePoints).join(',') !== 'hadOutput,outDir,schemaVersion,scope' + || record.schemaVersion !== 2 || record.outDir !== expectedOutDir + || (record.scope !== 'full' && record.scope !== 'subset') || typeof record.hadOutput !== 'boolean') { + throw new Error('Managed output transaction marker is invalid.'); + } + return Object.freeze({ + schemaVersion: 2, + outDir: record.outDir, + scope: record.scope, + hadOutput: record.hadOutput, + }) as TransactionRecord; +} + +/** @returns 字节的 SHA-256 十六进制摘要。 */ +function hashBytes(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +/** + * 规范化 transaction scope 并校验与 Unit Platform 集合完全一致。 + * + * @param scope 调用方选择语义。 + * @param units 本轮待提交 Package Units。 + * @returns frozen full/subset scope。 + */ +export function normalizeScope( + scope: ManagedOutputScope | undefined, + units: readonly PackageUnitSnapshot[], +): ManagedOutputScope { + if (scope === undefined || scope.type === 'full') + return Object.freeze({ type: 'full' }); + if (scope.type !== 'subset' || !Array.isArray(scope.platforms)) + throw new TypeError('Managed output scope is invalid.'); + /** selected IDs 复制、排序并拒绝不稳定或重复值。 */ + const selected = [...scope.platforms].sort(compareCodePoints); + if (selected.length === 0 || selected.some(platform => !STABLE_ID.test(platform)) + || new Set(selected).size !== selected.length) { + throw new TypeError('Subset Platform ids must be unique lowercase kebab-case values.'); + } + /** 成功提交时每个 selected Platform 必须至少存在一个 Unit。 */ + const actual = [...new Set(units.map(unit => unit.platform))].sort(compareCodePoints); + if (JSON.stringify(actual) !== JSON.stringify(selected)) + throw new TypeError('Subset Platform ids must exactly match the Package Unit Platform set.'); + return Object.freeze({ type: 'subset', platforms: Object.freeze(selected) }); +} + +/** 单个受管输出对应的固定物理路径协议。 */ +export interface ManagedOutputPaths { + readonly resolved: string; + readonly parent: string; + readonly base: string; + readonly lock: string; + readonly transaction: string; + readonly transactionWriting: string; + readonly committed: string; + readonly committedWriting: string; + readonly backup: string; + readonly stagePrefix: string; +} + +/** + * 验证 outDir 边界并形成全部固定事务路径。 + * + * @param outDir 受管输出目录。 + * @param projectRoot 工程根目录。 + * @returns 同一受管输出的不可变路径集合。 + */ +export function outputPaths(outDir: string, projectRoot: string): ManagedOutputPaths { + /** 输入路径先解析为绝对位置再判断边界。 */ + const resolved = path.resolve(outDir); + /** 工程根同样固定为绝对路径。 */ + const project = path.resolve(projectRoot); + /** basename 用于构造同级事务辅助路径。 */ + const base = path.basename(resolved); + if (!isInsidePath(project, resolved) || resolved === project + || resolved === path.parse(resolved).root || base === '' || base === '.' || base === '..') { + throw new Error('Managed output must stay strictly inside the project root.'); + } + /** 所有辅助路径与 outDir 同级,保证 rename 不跨文件系统。 */ + const parent = path.dirname(resolved); + /** pending 与 committed marker 使用固定、互不覆盖的名称。 */ + const transaction = path.join(parent, `.${base}.acplugin-transaction.json`); + /** committed marker 只在 cleanup 必要条件完成后发布。 */ + const committed = path.join(parent, `.${base}.acplugin-committed.json`); + return Object.freeze({ + resolved, + parent, + base, + lock: path.join(parent, `.${base}.acplugin.lock`), + transaction, + transactionWriting: `${transaction}${MARKER_WRITING_SUFFIX}`, + committed, + committedWriting: `${committed}${MARKER_WRITING_SUFFIX}`, + backup: path.join(parent, `.${base}.acplugin-backup`), + stagePrefix: `.${base}.acplugin-stage-`, + }); +} + +/** + * 读取一个未选 Platform 的完整旧输出,拒绝非普通内容和路径碰撞。 + * + * @param root Platform 物理根。 + * @param id Platform ID。 + * @returns 可复制并在 swap 前复核的内存快照。 + */ +async function snapshotPreservedPlatform(root: string, id: string): Promise { + /** scanPhysicalTree 统一拒绝 symlink/special file。 */ + const tree = await scanPhysicalTree(root); + /** 路径索引额外拒绝大小写和 NFC collision。 */ + const collision = new Map(); + for (const relative of [...tree.directories, ...tree.files]) { + /** 所有目录和文件共享同一个折叠 collision domain。 */ + const key = sourceCollisionKey(relative); + /** 首次出现的原始 path 用于稳定诊断。 */ + const previous = collision.get(key); + if (previous !== undefined) + throw new Error(`Preserved Platform path "${relative}" collides with "${previous}".`); + collision.set(key, relative); + } + /** file snapshots 与目录 closure 分开保存。 */ + const files: PreservedFile[] = []; + for (const relative of tree.files) { + /** 文件字节一次性复制,旧输出不会成为新 AssetRef 来源。 */ + const file = path.join(root, ...relative.split('/')); + /** mode 只接受框架 Asset 支持的两种权限。 */ + const stat = await fs.lstat(file); + /** 权限去除文件类型位后参与 snapshot。 */ + const mode = stat.mode & 0o777; + if (mode !== 0o644 && mode !== 0o755) + throw new Error(`Preserved Platform file has unsupported mode: ${id}/${relative}.`); + /** 内容 snapshot 同时固定 size/hash。 */ + const bytes = Uint8Array.from(await fs.readFile(file)); + files.push(Object.freeze({ + path: relative, + bytes, + mode, + size: bytes.byteLength, + sha256: hashBytes(bytes), + })); + } + return Object.freeze({ id, directories: tree.directories, files: Object.freeze(files) }); +} + +/** + * 在取得 transaction lock 后快照所有未选 Platform。 + * + * @param outDir 当前受管输出。 + * @param selected 本轮显式替换的 Platform。 + * @returns 按 Platform ID 排序的旧输出快照。 + */ +export async function snapshotPreservedPlatforms( + outDir: string, + selected: ReadonlySet, +): Promise { + if (!await exists(outDir)) + return Object.freeze([]); + /** outDir 自身也不能是 symlink 或普通文件。 */ + const stat = await fs.lstat(outDir); + if (stat.isSymbolicLink() || !stat.isDirectory()) + throw new Error('Managed output root must be a regular directory.'); + /** outDir 顶层只能包含 lowercase-kebab Platform 目录。 */ + const entries = (await fs.readdir(outDir, { withFileTypes: true })) + .sort((left, right) => compareCodePoints(left.name, right.name)); + /** 未选 Platform 按目录顺序进入快照。 */ + const preserved: PreservedPlatform[] = []; + /** 顶层 Platform ID 也拒绝 case/NFC collision。 */ + const collisions = new Map(); + for (const entry of entries) { + if (!STABLE_ID.test(entry.name) || !entry.isDirectory() || entry.isSymbolicLink()) + throw new Error(`Managed output contains an invalid Platform root: "${entry.name}".`); + /** Platform ID 使用与 Package path 相同的折叠 key。 */ + const key = sourceCollisionKey(entry.name); + /** 首次 Platform 名用于冲突诊断。 */ + const previous = collisions.get(key); + if (previous !== undefined) + throw new Error(`Managed output Platform "${entry.name}" collides with "${previous}".`); + collisions.set(key, entry.name); + if (!selected.has(entry.name)) + preserved.push(await snapshotPreservedPlatform(path.join(outDir, entry.name), entry.name)); + } + return Object.freeze(preserved); +} + +/** + * 把未选 Platform snapshot 写入 stage。 + * + * @param stage 当前事务 stage 根。 + * @param platforms 旧输出内存快照。 + */ +export async function materializePreservedPlatforms( + stage: string, + platforms: readonly PreservedPlatform[], +): Promise { + for (const platform of platforms) { + /** Platform 根本身即使为空也必须保留。 */ + const root = path.join(stage, platform.id); + await fs.mkdir(root, { recursive: true, mode: 0o700 }); + for (const directory of platform.directories) + await fs.mkdir(path.join(root, ...directory.split('/')), { recursive: true, mode: 0o700 }); + for (const file of platform.files) { + /** 文件写入不复用 copyFile,确保使用已快照的确定字节。 */ + const destination = path.join(root, ...file.path.split('/')); + await fs.mkdir(path.dirname(destination), { recursive: true, mode: 0o700 }); + await fs.writeFile(destination, file.bytes, { flag: 'wx', mode: file.mode }); + await fs.chmod(destination, file.mode); + } + } +} + +/** + * 复核保留 Platform 的树闭包、字节和 mode。 + * + * @param parent outDir 或 stage 根。 + * @param platforms 先前建立的完整快照。 + */ +export async function validatePreservedPlatforms( + parent: string, + platforms: readonly PreservedPlatform[], +): Promise { + for (const platform of platforms) { + /** preserved validation 始终从 Platform root 开始。 */ + const root = path.join(parent, platform.id); + /** closure 比较拒绝外部在 snapshot 后增删文件或目录。 */ + const tree = await scanPhysicalTree(root); + if (JSON.stringify(tree.directories) !== JSON.stringify(platform.directories) + || JSON.stringify(tree.files) !== JSON.stringify(platform.files.map(file => file.path))) { + throw new Error(`Preserved Platform tree changed during transaction: ${platform.id}.`); + } + for (const expected of platform.files) { + /** 每个文件重新读取以验证 source/stage 都等于同一 snapshot。 */ + const file = path.join(root, ...expected.path.split('/')); + /** mode 从 lstat 获取,避免最终 symlink 跟随。 */ + const stat = await fs.lstat(file); + /** bytes 再次复算 size/hash。 */ + const bytes = Uint8Array.from(await fs.readFile(file)); + if ((stat.mode & 0o777) !== expected.mode || bytes.byteLength !== expected.size + || hashBytes(bytes) !== expected.sha256) { + throw new Error(`Preserved Platform file changed during transaction: ${platform.id}/${expected.path}.`); + } + } + } +} + +/** + * 校验 stage 顶层只包含本轮 Unit 与保留 Platform 的完整集合。 + * + * @param stage 当前 stage 根。 + * @param units 本轮新 Package Units。 + * @param preserved 未选 Platform snapshots。 + */ +export async function validateStagePlatforms( + stage: string, + units: readonly PackageUnitSnapshot[], + preserved: readonly PreservedPlatform[], +): Promise { + /** expected 顶层由新 Unit Platform 与 preserved Platform 并集组成。 */ + const expected = [...new Set([ + ...units.map(unit => unit.platform), + ...preserved.map(platform => platform.id), + ])].sort(compareCodePoints); + /** stage 顶层实际目录集合也必须完整闭合。 */ + const actual = (await fs.readdir(stage, { withFileTypes: true })) + .map((entry) => { + if (!entry.isDirectory() || entry.isSymbolicLink()) + throw new Error(`Managed stage contains a non-directory Platform root: "${entry.name}".`); + return entry.name; + }) + .sort(compareCodePoints); + if (JSON.stringify(actual) !== JSON.stringify(expected)) + throw new Error('Managed stage Platform closure mismatch.'); +} + +/** 把即将 swap 的最终 stage 全部目录规范为公开可遍历的 0755。 */ +export async function normalizeFinalDirectoryModes(stage: string): Promise { + if (process.platform === 'win32') + return; + /** scan 先证明整棵 stage 不含 symlink 或特殊文件。 */ + const tree = await scanPhysicalTree(stage); + /** 后代先 chmod,最后处理会成为 outDir 的 stage root。 */ + for (const directory of tree.directories) + await fs.chmod(path.join(stage, ...directory.split('/')), 0o755); + await fs.chmod(stage, 0o755); +} + +/** 复核 stage 根和所有后代目录的最终 POSIX mode。 */ +export async function validateFinalDirectoryModes(stage: string): Promise { + if (process.platform === 'win32') + return; + /** scan 同时返回完整目录闭包并拒绝非普通内容。 */ + const tree = await scanPhysicalTree(stage); + for (const directory of ['', ...tree.directories]) { + /** 空字符串表示最终 outDir 根自身。 */ + const physical = directory === '' ? stage : path.join(stage, ...directory.split('/')); + /** lstat 复核当前目录没有被替换且使用最终公开 mode。 */ + const stat = await fs.lstat(physical); + if ((stat.mode & 0o777) !== 0o755) + throw new Error('Managed stage directories must use mode 0755.'); + } +} diff --git a/packages/core/src/output/transaction.ts b/packages/core/src/output/transaction.ts new file mode 100644 index 0000000..06e7f25 --- /dev/null +++ b/packages/core/src/output/transaction.ts @@ -0,0 +1,201 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { PackageUnitSnapshot } from '../contracts/packages.js'; +import { AssetRegistry } from '../services/assets.js'; +import { validatePhysicalEntry } from '../security/path-policy.js'; +import { materializePackageUnits, validatePackageUnits } from '../package/candidate-materializer.js'; +import { acquireManagedOutputLock, releaseManagedOutputLock } from './lock.js'; +import { recoverManagedOutput } from './recovery.js'; +import { + exists, + materializePreservedPlatforms, + normalizeFinalDirectoryModes, + normalizeScope, + outputPaths, + snapshotPreservedPlatforms, + validateFinalDirectoryModes, + validatePreservedPlatforms, + validateStagePlatforms, + writeTransactionMarker, + type ManagedOutputScope, + type TransactionRecord, +} from './transaction-files.js'; + +export type { ManagedOutputScope } from './transaction-files.js'; + +/** 受管输出事务可观测的稳定阶段名称。 */ +export type ManagedOutputPhase + = | 'lock-acquired' + | 'recovery-complete' + | 'stage-materialized' + | 'stage-validated' + | 'transaction-written' + | 'backup-created' + | 'output-swapped'; + +/** Package Unit 集合原子提交选项。 */ +export interface CommitPackageUnitsOptions { + /** outDir 必须严格位于该工程根内部。 */ + readonly projectRoot: string; + /** 默认 full;subset 会在 stage 中保留未选 Platform 的既有输出。 */ + readonly scope?: ManagedOutputScope; + /** + * 在事务进入关键阶段时调用,用于内部观测和 fault injection。 + * + * @param phase 已经完成的事务阶段。 + */ + readonly onPhase?: (phase: ManagedOutputPhase) => void | Promise; + /** swap 后、删除 rollback backup 前执行的 Core 收尾。 */ + readonly afterSwap?: () => void | Promise; +} +/** + * 原子提交全部 selected Package Units。 + * + * @param outDir 框架完全管理的输出目录。 + * @param units 已完成 candidate/compatibility 校验的 Package Units。 + * @param assets 当前 BuildSession Asset Registry。 + * @param options 工程边界、scope 和 fault-injection hooks。 + */ +export async function commitPackageUnits( + outDir: string, + units: readonly PackageUnitSnapshot[], + assets: AssetRegistry, + options: CommitPackageUnitsOptions, +): Promise { + /** 所有路径、scope 输入在创建锁或辅助文件前完成验证。 */ + const locations = outputPaths(outDir, options.projectRoot); + /** scope 与本轮 Unit Platform set 精确绑定。 */ + const scope = normalizeScope(options.scope, units); + await fs.mkdir(locations.parent, { recursive: true }); + /** project→parent 的每层必须是非 symlink 普通目录。 */ + await validatePhysicalEntry(path.resolve(options.projectRoot), locations.parent, 'directory'); + if (await exists(locations.resolved)) + await validatePhysicalEntry(path.resolve(options.projectRoot), locations.resolved, 'directory'); + /** 三个持久辅助路径与 outDir 同级,保证 rename 不跨文件系统。 */ + const lockPath = locations.lock; + /** transaction record 用于崩溃恢复。 */ + const transactionPath = locations.transaction; + /** cleanup 完成后写入的 marker 将 pending transaction 提升为正式提交。 */ + const committedPath = locations.committed; + /** backup 保存 swap 前的完整旧目录。 */ + const backupPath = locations.backup; + /** 当前调用创建但尚未 swap 的 stage。 */ + let stage: string | undefined; + /** rollback 判断旧输出是否已经移动。 */ + let backupCreated = false; + /** rollback 判断新输出是否已经暴露。 */ + let outputSwapped = false; + /** 仅清理当前调用已经创建的 transaction marker。 */ + let transactionWritten = false; + + /** lock token 从 recovery 一直持有到 cleanup 完成。 */ + const lockToken = await acquireManagedOutputLock(lockPath); + try { + await options.onPhase?.('lock-acquired'); + await recoverManagedOutput(locations, options.projectRoot); + await options.onPhase?.('recovery-complete'); + + /** subset 在锁内快照未选 Platform;full 使用空保留集。 */ + const preserved = scope.type === 'subset' + ? await snapshotPreservedPlatforms(locations.resolved, new Set(scope.platforms)) + : Object.freeze([]); + stage = await fs.mkdtemp(path.join(locations.parent, locations.stagePrefix)); + /** 先放入旧未选 Platform,再写入本轮 selected Units。 */ + await materializePreservedPlatforms(stage, preserved); + /** selected Units 直接从 AssetRegistry 做 TOCTOU materialization。 */ + const materialized = await materializePackageUnits(stage, units, assets); + /** 只有完整 stage 即将验证/swap 时才从私有 0700 规范为最终 0755。 */ + await normalizeFinalDirectoryModes(stage); + await options.onPhase?.('stage-materialized'); + /** selected Units、preserved Platforms 与 stage 顶层分别完成闭包验证。 */ + await validatePackageUnits(stage, units, materialized); + await validatePreservedPlatforms(stage, preserved); + await validateStagePlatforms(stage, units, preserved); + await validateFinalDirectoryModes(stage); + /** swap 前再次复核旧未选 Platform 没有在 snapshot 后变化。 */ + if (preserved.length > 0) + await validatePreservedPlatforms(locations.resolved, preserved); + await options.onPhase?.('stage-validated'); + /** record 只含相对 basename、scope 和旧输出存在性,不记录绝对路径。 */ + const transactionRecord: TransactionRecord = Object.freeze({ + schemaVersion: 2, + outDir: locations.base, + scope: scope.type, + hadOutput: await exists(locations.resolved), + }); + await writeTransactionMarker(transactionPath, transactionRecord); + transactionWritten = true; + await options.onPhase?.('transaction-written'); + + if (await exists(locations.resolved)) { + await fs.rename(locations.resolved, backupPath); + backupCreated = true; + } + try { + await options.onPhase?.('backup-created'); + await fs.rename(stage, locations.resolved); + stage = undefined; + outputSwapped = true; + await options.onPhase?.('output-swapped'); + await options.afterSwap?.(); + /** 只有必要 cleanup 成功后,崩溃恢复才允许保留新输出。 */ + await writeTransactionMarker(committedPath, transactionRecord); + } catch (error) { + try { + /** afterSwap/rename 失败统一恢复旧输出。 */ + if (outputSwapped && await exists(locations.resolved)) + await fs.rm(locations.resolved, { recursive: true, force: true }); + if (backupCreated && await exists(backupPath)) + await fs.rename(backupPath, locations.resolved); + } catch (rollbackError) { + throw new AggregateError([error, rollbackError], 'Managed output rollback failed.', { cause: rollbackError }); + } + throw error; + } + if (await exists(backupPath)) { + try { + await fs.rm(backupPath, { recursive: true, force: true }); + } catch { + /** committed marker 保留到下次 recovery 删除过期 backup。 */ + return; + } + } + /** backup 已清理后才可删除恢复 record;committed marker 最后删除。 */ + try { + await fs.rm(transactionPath, { force: true }); + transactionWritten = false; + } catch { + /** 两个 marker 留给下次 recovery 确认新输出已提交。 */ + return; + } + try { + await fs.rm(committedPath, { force: true }); + } catch { + /** 单独的 committed marker 同样可由下次 recovery 安全清理。 */ + } + } catch (error) { + if (!(error instanceof AggregateError) && transactionWritten) { + try { + await fs.rm(transactionPath, { force: true }); + await fs.rm(committedPath, { force: true }); + transactionWritten = false; + } catch { + /** 无法清理的 marker 是下一轮可恢复状态。 */ + } + } + throw error; + } finally { + if (stage !== undefined) { + try { + await fs.rm(stage, { recursive: true, force: true }); + } catch { + /** stage 清理失败不覆盖原始 transaction 结果。 */ + } + } + try { + await releaseManagedOutputLock(lockPath, lockToken); + } catch { + /** 遗留 record 已撤销 active token,将由同 PID 的下一轮识别为 stale。 */ + } + } +} diff --git a/packages/core/src/package/candidate-materializer.ts b/packages/core/src/package/candidate-materializer.ts new file mode 100644 index 0000000..fa12d61 --- /dev/null +++ b/packages/core/src/package/candidate-materializer.ts @@ -0,0 +1,329 @@ +import { createHash } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { + PackageCandidate, + PackageUnitSnapshot, +} from '../contracts/packages.js'; +import { AssetRegistry } from '../services/assets.js'; +import { compareCodePoints, safeRelativePath, sourceCollisionKey } from '../security/path-policy.js'; + +/** Package Unit Platform/ID 共用的 lowercase-kebab 规则。 */ +const STABLE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + +/** Candidate handle 将绝对临时路径限制在 validator 调用窗口。 */ +export interface PackageCandidateHandle { + readonly candidate: PackageCandidate; + /** validator 返回后复核完整树、字节和 mode。 */ + readonly validate: () => Promise; + /** 无论成功失败都幂等移除 candidate。 */ + readonly cleanup: () => Promise; +} + +/** 单个 Package Unit 预检后的完整路径和 Asset metadata。 */ +export interface MaterializationEntry { + readonly path: string; + readonly asset: PackageUnitSnapshot['assets'][number]['asset']; + readonly mode: 0o644 | 0o755; + readonly size: number; + readonly sha256: string; +} + +/** @returns 文件字节的 SHA-256 十六进制摘要。 */ +function hashBytes(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +/** + * 验证一个 Unit 的身份、Asset snapshot 和完整路径闭包。 + * + * @param unit Core 建立的 Package Unit snapshot。 + * @param assets 当前 BuildSession Asset Registry。 + * @returns 按路径排序的物化输入。 + */ +function preflightUnit(unit: PackageUnitSnapshot, assets: AssetRegistry): readonly MaterializationEntry[] { + if (!STABLE_ID.test(unit.platform) || !STABLE_ID.test(unit.id)) + throw new TypeError('Package Unit Platform and id must use lowercase kebab-case.'); + if ((unit.role === 'primary' && unit.type === 'marketplace') + || (unit.role === 'distribution' && unit.type !== 'marketplace')) { + throw new TypeError('Package Unit role and type are inconsistent.'); + } + /** exact/case/NFC 与文件/目录前缀共用一个本地索引。 */ + const paths = new Map(); + /** entries 保留 materialization 所需的完整性基准。 */ + const entries: MaterializationEntry[] = []; + /** Unit 的 Platform owner 必须拥有所有 ref grant。 */ + const owner = `platform:${unit.platform}`; + for (const mapping of unit.assets) { + /** Unit path 在接触文件系统前通过完整路径策略。 */ + const safe = safeRelativePath(mapping.path); + /** collision key 折叠大小写和 NFC。 */ + const key = sourceCollisionKey(safe); + for (const [existingKey, existingPath] of paths) { + if (key === existingKey || key.startsWith(`${existingKey}/`) || existingKey.startsWith(`${key}/`)) + throw new TypeError(`Package Asset path "${safe}" collides with "${existingPath}".`); + } + paths.set(key, safe); + /** describe 复核 ref identity、Platform grant、BuildSession 与 issuer owner。 */ + const record = assets.describe(owner, mapping.asset); + if (record.owner !== mapping.owner) + throw new TypeError(`Package Asset owner mismatch at "${safe}".`); + entries.push(Object.freeze({ + path: safe, + asset: mapping.asset, + mode: record.mode, + size: record.size, + sha256: record.sha256, + })); + } + return Object.freeze(entries.sort((left, right) => compareCodePoints(left.path, right.path))); +} + +/** + * 把一个 Unit 的全部 AssetRef 写入一个新建空目录。 + * + * @param root 当前 Unit 独占物化根。 + * @param unit Package Unit snapshot。 + * @param assets 当前 Asset Registry。 + * @returns 后续完整性复核使用的稳定 entries。 + */ +async function materializeUnitRoot( + root: string, + unit: PackageUnitSnapshot, + assets: AssetRegistry, +): Promise { + /** preflight 必须先完整成功,不能写出部分不合法 Unit。 */ + const entries = preflightUnit(unit, assets); + /** 所有 Asset 读取都使用 Platform owner grant。 */ + const owner = `platform:${unit.platform}`; + await fs.mkdir(root, { recursive: true, mode: 0o700 }); + for (const entry of entries) { + /** safe POSIX segments 逐段交给宿主 path join。 */ + const destination = path.join(root, ...entry.path.split('/')); + await fs.mkdir(path.dirname(destination), { recursive: true, mode: 0o700 }); + /** materializationBytes 在每次落盘前重新执行来源 TOCTOU 校验。 */ + const bytes = await assets.materializationBytes(owner, entry.asset); + if (bytes.byteLength !== entry.size || hashBytes(bytes) !== entry.sha256) + throw new Error(`Package Asset changed before materialization: ${unit.platform}/${unit.id}/${entry.path}.`); + await fs.writeFile(destination, bytes, { flag: 'wx', mode: entry.mode }); + await fs.chmod(destination, entry.mode); + } + return entries; +} + +/** + * 递归收集物化树中的全部目录和普通文件,拒绝 symlink 与特殊文件。 + * + * @param root 当前 Unit 物化根。 + * @returns 工程无关的 POSIX 相对路径集合。 + */ +export async function scanPhysicalTree(root: string): Promise<{ + readonly files: readonly string[]; + readonly directories: readonly string[]; +}> { + /** root 自身也必须保持普通目录,不能被 validator 替换为 symlink。 */ + const rootStat = await fs.lstat(root); + if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) + throw new Error('Materialized Package root must remain a regular directory.'); + /** 文件和目录分开比较,防止 validator 注入空目录。 */ + const files: string[] = []; + /** directory closure 包含所有显式和隐式目录。 */ + const directories: string[] = []; + /** 递归只沿 lstat 已证明的普通目录进入。 */ + const visit = async (relative: string): Promise => { + /** root 使用空 relative,后代按 POSIX segment 转宿主路径。 */ + const current = relative.length === 0 ? root : path.join(root, ...relative.split('/')); + /** readdir 结果显式按 code point 排序,消除文件系统顺序差异。 */ + const entries = (await fs.readdir(current, { withFileTypes: true })) + .sort((left, right) => compareCodePoints(left.name, right.name)); + for (const entry of entries) { + /** 文件系统名称也必须可无歧义表示为安全相对路径。 */ + const child = relative.length === 0 ? entry.name : `${relative}/${entry.name}`; + safeRelativePath(child); + /** child 的物理位置只由安全 segment 拼接。 */ + const physical = path.join(root, ...child.split('/')); + /** lstat 保证符号链接不会被跟随。 */ + const stat = await fs.lstat(physical); + if (stat.isSymbolicLink()) + throw new Error(`Materialized Package contains a symbolic link at "${child}".`); + if (stat.isDirectory()) { + directories.push(child); + await visit(child); + } else if (stat.isFile()) { + files.push(child); + } else { + throw new Error(`Materialized Package contains a special file at "${child}".`); + } + } + }; + await visit(''); + /** DFS 完成后全局排序,避免嵌套前序与 code-point 顺序不一致。 */ + files.sort(compareCodePoints); + directories.sort(compareCodePoints); + return Object.freeze({ files: Object.freeze(files), directories: Object.freeze(directories) }); +} + +/** @returns 期望文件路径隐含的完整目录集合。 */ +function expectedDirectories(files: readonly string[]): readonly string[] { + /** 多个文件共享目录时使用 Set 去重。 */ + const directories = new Set(); + for (const file of files) { + /** 每个文件逐级产生其父目录前缀。 */ + const segments = file.split('/'); + for (let length = 1; length < segments.length; length += 1) + directories.add(segments.slice(0, length).join('/')); + } + return Object.freeze([...directories].sort(compareCodePoints)); +} + +/** + * 复核 Unit 物化后的完整树、文件字节、权限和摘要。 + * + * @param root 当前 Unit 物化根。 + * @param unit Package Unit identity。 + * @param entries preflight 建立的完整性基准。 + */ +async function validateUnitRoot( + root: string, + unit: PackageUnitSnapshot, + entries: readonly MaterializationEntry[], +): Promise { + /** tree closure 同时拒绝额外文件和额外空目录。 */ + const tree = await scanPhysicalTree(root); + /** entries 本身已按 path 排序。 */ + const expectedFiles = entries.map(entry => entry.path); + if (JSON.stringify(tree.files) !== JSON.stringify(expectedFiles) + || JSON.stringify(tree.directories) !== JSON.stringify(expectedDirectories(expectedFiles))) { + throw new Error(`Materialized Package tree closure mismatch: ${unit.platform}/${unit.id}.`); + } + for (const entry of entries) { + /** 已通过 closure 的目标必然是普通文件且没有 symlink 祖先。 */ + const file = path.join(root, ...entry.path.split('/')); + /** mode 由 lstat 读取,不跟随最终 symlink。 */ + const stat = await fs.lstat(file); + /** bytes 用于独立复算 size/hash。 */ + const bytes = Uint8Array.from(await fs.readFile(file)); + if (bytes.byteLength !== entry.size || hashBytes(bytes) !== entry.sha256) + throw new Error(`Materialized Package integrity mismatch: ${unit.platform}/${unit.id}/${entry.path}.`); + if ((stat.mode & 0o777) !== entry.mode) + throw new Error(`Materialized Package mode mismatch: ${unit.platform}/${unit.id}/${entry.path}.`); + } +} + +/** + * 在 Core 临时目录建立一个只在 validator 窗口有效的 Package candidate。 + * + * @param unit 已冻结 Package Unit。 + * @param assets 当前 Asset Registry。 + * @param temporaryParent 可选受管临时父目录。 + * @returns 含 validate/cleanup 的候选句柄。 + */ +export async function materializePackageCandidate( + unit: PackageUnitSnapshot, + assets: AssetRegistry, + temporaryParent: string = os.tmpdir(), +): Promise { + await fs.mkdir(temporaryParent, { recursive: true, mode: 0o700 }); + /** mkdtemp 产生只属于当前 candidate 的物理根。 */ + const root = await fs.mkdtemp(path.join(temporaryParent, 'acplugin-candidate-')); + try { + /** candidate 创建时先完成一次全量物化。 */ + const entries = await materializeUnitRoot(root, unit, assets); + await validateUnitRoot(root, unit, entries); + /** cleaned 保证 validator 与错误路径可重复调用 cleanup。 */ + let cleaned = false; + return Object.freeze({ + candidate: Object.freeze({ root, unit }), + /** validate 不信任 Platform callback 返回后的磁盘状态。 */ + validate: () => validateUnitRoot(root, unit, entries), + /** cleanup 可由 finally 和调用方重复安全执行。 */ + cleanup: async () => { + if (cleaned) + return; + cleaned = true; + await fs.rm(root, { recursive: true, force: true }); + }, + }); + } catch (error) { + await fs.rm(root, { recursive: true, force: true }); + throw error; + } +} + +/** + * 执行 Platform validator 并在返回后复核 candidate 未被修改。 + * + * @param unit 当前 Package Unit。 + * @param assets 当前 Asset Registry。 + * @param validate Platform validator callback。 + * @param temporaryParent 可选受管 candidate 父目录。 + */ +export async function withPackageCandidate( + unit: PackageUnitSnapshot, + assets: AssetRegistry, + validate: (candidate: PackageCandidate) => void | Promise, + temporaryParent?: string, +): Promise { + /** candidate handle 的生命周期严格包围一次 validator 调用。 */ + const handle = await materializePackageCandidate(unit, assets, temporaryParent); + try { + await validate(handle.candidate); + await handle.validate(); + } finally { + await handle.cleanup(); + } +} + +/** + * 把全部 Package Unit 写入 `/` 两级 stage 布局。 + * + * @param root 新建 stage 根。 + * @param units 本轮 selected Package Units。 + * @param assets 当前 Asset Registry。 + * @returns 每个 Unit root 的完整性基准。 + */ +export async function materializePackageUnits( + root: string, + units: readonly PackageUnitSnapshot[], + assets: AssetRegistry, +): Promise> { + /** Unit roots 拒绝 Platform/ID 重复。 */ + const roots = new Map(); + await fs.mkdir(root, { recursive: true, mode: 0o700 }); + /** Unit 完成顺序不影响 stage 物化顺序。 */ + const ordered = [...units].sort((left, right) => compareCodePoints(left.platform, right.platform) || compareCodePoints(left.id, right.id)); + for (const unit of ordered) { + /** 两级 root 只来自已验证 lowercase-kebab identities。 */ + const key = `${unit.platform}/${unit.id}`; + if (roots.has(key)) + throw new TypeError(`Duplicate Package Unit "${key}".`); + /** Unit 物理 root 固定为 `/`。 */ + const directory = path.join(root, unit.platform, unit.id); + roots.set(key, await materializeUnitRoot(directory, unit, assets)); + } + return roots; +} + +/** + * 复核已由 materializePackageUnits 写出的完整 selected Unit 集合。 + * + * @param root stage 根。 + * @param units 当前 selected Units。 + * @param entries 物化时建立的每 Unit 基准。 + */ +export async function validatePackageUnits( + root: string, + units: readonly PackageUnitSnapshot[], + entries: ReadonlyMap, +): Promise { + for (const unit of units) { + /** 每个 Unit 必须存在对应 preflight 基准。 */ + const key = `${unit.platform}/${unit.id}`; + /** materialization baseline 不能由 validator 或 transaction 补造。 */ + const expected = entries.get(key); + if (expected === undefined) + throw new Error(`Package Unit materialization baseline is missing: ${key}.`); + await validateUnitRoot(path.join(root, unit.platform, unit.id), unit, expected); + } +} diff --git a/packages/core/src/package/compatibility.ts b/packages/core/src/package/compatibility.ts new file mode 100644 index 0000000..9e35bb1 --- /dev/null +++ b/packages/core/src/package/compatibility.ts @@ -0,0 +1,387 @@ +import type { CanonicalProject } from '../contracts/components.js'; +/** Core 集中处理目标平台兼容性结论。 */ +import type { + CompatibilityEntry, + MetadataDispositionEntry, +} from '../contracts/reports.js'; +import type { + CompatibilityInput, + CompatibilityLevel, + MetadataDispositionInput, +} from '../contracts/integrations.js'; +import type { PluginMetadata } from '../contracts/config.js'; +import { DiagnosticRegistry } from '../services/diagnostics.js'; +import { snapshotJson } from '../security/json-snapshot.js'; +import { compareCodePoints, safeRelativePath } from '../security/path-policy.js'; +import { sanitizeStableText } from '../security/report-safety.js'; + +/** subject/capability/field/transformation/cause 使用的稳定结构化身份。 */ +const STABLE_REFERENCE = /^[a-z0-9]+(?:[-.:/][a-z0-9]+)*$/u; + +/** cause 使用的 `(subject)#(capability)` tuple key。 */ +const TUPLE_REFERENCE = /^[a-z0-9]+(?:[-.:/][a-z0-9]+)*#[a-z0-9]+(?:[-.:/][a-z0-9]+)*$/u; + +/** 规范 metadata 字段保留 camelCase,并可用点号表达 author 子字段。 */ +const METADATA_FIELD = /^[A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z][A-Za-z0-9]*)*$/u; + +/** 兼容性等级从最好到最差的传播顺序。 */ +const LEVEL_WEIGHT: Readonly> = Object.freeze({ + native: 0, + transform: 1, + degraded: 2, + unsupported: 3, +}); + +/** @returns `(subject, capability)` 的无歧义稳定 cause key。 */ +export function compatibilityTupleKey(subject: string, capability: string): string { + return `${subject}#${capability}`; +} + +/** @returns 当前 tuple entry 的稳定 cause key。 */ +function entryKey(entry: Pick): string { + return compatibilityTupleKey(entry.subject, entry.capability); +} + +/** + * 验证稳定的单行人类说明。 + * + * @param value 未受信任 reason。 + * @param label 字段标签。 + * @returns 原始说明文本。 + */ +function reason(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0) + throw new TypeError(`${label} must be a non-empty stable single-line string.`); + /** Compatibility/metadata reason 与 diagnostics 共用报告安全边界。 */ + const safe = sanitizeStableText(value); + if (safe.length === 0) + throw new TypeError(`${label} must not become empty after sanitization.`); + return safe; +} + +/** + * 验证不承载任意日志文本的稳定身份。 + * + * @param value 未知结构化引用。 + * @param label 字段标签。 + * @returns 合法原始文本。 + */ +function stableReference(value: unknown, label: string): string { + if (typeof value !== 'string' || !STABLE_REFERENCE.test(value)) + throw new TypeError(`${label} must be a stable lowercase reference.`); + return value; +} + +/** @returns 已验证且不承载路径语义的规范 metadata 字段。 */ +function metadataField(value: unknown): string { + if (typeof value !== 'string' || !METADATA_FIELD.test(value)) + throw new TypeError('Metadata field must be a stable field reference.'); + return value; +} + +/** @returns compatibility cause 是否为稳定 tuple key。 */ +function tupleReference(value: unknown): string { + if (typeof value !== 'string' || !TUPLE_REFERENCE.test(value)) + throw new TypeError('Compatibility cause must be a stable subject#capability tuple key.'); + return value; +} + +/** + * 复制并验证单条 Platform 兼容性输入。 + * + * @param platform Core 绑定的平台 ID。 + * @param input Integration 返回的输入。 + * @returns 绑定 Platform 且深度冻结的条目。 + */ +export function snapshotCompatibility(platform: string, input: CompatibilityInput): CompatibilityEntry { + /** 首先建立无行为 JSON snapshot,后续不再读取原始输入。 */ + const value = snapshotJson(input, 'Compatibility input'); + if (typeof value !== 'object' || value === null || Array.isArray(value)) + throw new TypeError('Compatibility input must be an object.'); + /** 未知字段不能隐式进入 report schema。 */ + const allowed = new Set(['subject', 'capability', 'level', 'transformation', 'reason', 'causes']); + if (Object.keys(value).some(field => !allowed.has(field))) + throw new TypeError('Compatibility input contains unknown fields.'); + /** 严格 JSON snapshot 已移除 getter、class、Symbol、cycle 与调用方 mutation。 */ + const snapshot = value as unknown as CompatibilityInput; + if (!Object.hasOwn(LEVEL_WEIGHT, snapshot.level)) + throw new TypeError('Compatibility level is invalid.'); + /** causes 按结构化 tuple key 排序并拒绝重复。 */ + const causes = snapshot.causes?.map(tupleReference).sort(compareCodePoints); + if (causes !== undefined && new Set(causes).size !== causes.length) + throw new TypeError('Compatibility causes must not contain duplicates.'); + return Object.freeze({ + platform: stableReference(platform, 'Platform'), + subject: stableReference(snapshot.subject, 'Compatibility subject'), + capability: stableReference(snapshot.capability, 'Compatibility capability'), + level: snapshot.level, + ...(snapshot.transformation === undefined ? {} : { transformation: stableReference(snapshot.transformation, 'Compatibility transformation') }), + reason: reason(snapshot.reason, 'Compatibility reason'), + ...(causes === undefined ? {} : { causes: Object.freeze(causes) }), + }); +} + +/** + * 复制并验证一条 metadata disposition。 + * + * @param platform Core 绑定的平台 ID。 + * @param input Platform 返回的输入。 + * @returns 绑定 Platform 且冻结的条目。 + */ +export function snapshotMetadata(platform: string, input: MetadataDispositionInput): MetadataDispositionEntry { + /** 首先建立无行为 JSON snapshot,后续不再读取原始输入。 */ + const value = snapshotJson(input, 'Metadata disposition'); + if (typeof value !== 'object' || value === null || Array.isArray(value)) + throw new TypeError('Metadata disposition must be an object.'); + /** disposition 只接受固定 report schema 字段。 */ + const allowed = new Set(['field', 'disposition', 'output', 'reason']); + if (Object.keys(value).some(field => !allowed.has(field))) + throw new TypeError('Metadata disposition contains unknown fields.'); + /** 后续字段只从无行为 JSON snapshot 读取。 */ + const snapshot = value as unknown as MetadataDispositionInput; + if (snapshot.disposition !== 'emitted' && snapshot.disposition !== 'omitted') + throw new TypeError('Metadata disposition is invalid.'); + /** output 可表达 package path 或稳定 Document field path。 */ + let output: string | undefined; + if (snapshot.output !== undefined) { + if (typeof snapshot.output !== 'string' || snapshot.output.length === 0 + || !/^[A-Za-z0-9@+._-]+(?:\/[A-Za-z0-9@+._-]+)*$/u.test(snapshot.output)) { + throw new TypeError('Metadata output must be a stable package path or field path.'); + } + safeRelativePath(snapshot.output); + output = snapshot.output; + } + return Object.freeze({ + platform: stableReference(platform, 'Platform'), + field: metadataField(snapshot.field), + disposition: snapshot.disposition, + ...(output === undefined ? {} : { output }), + reason: reason(snapshot.reason, 'Metadata reason'), + }); +} + +/** @returns 当前工程实际出现且必须被 Platform disposition 覆盖的 metadata 字段。 */ +export function metadataFields(metadata: PluginMetadata): readonly string[] { + /** 三个规范必填字段始终进入覆盖集合。 */ + const fields = ['name', 'version', 'description']; + for (const field of ['displayName', 'homepage', 'repository', 'license'] as const) { + if (metadata[field] !== undefined) + fields.push(field); + } + if (metadata.author !== undefined) { + fields.push('author.name'); + if (metadata.author.email !== undefined) + fields.push('author.email'); + if (metadata.author.url !== undefined) + fields.push('author.url'); + } + if (metadata.keywords.length > 0) + fields.push('keywords'); + return Object.freeze(fields.sort(compareCodePoints)); +} + +/** Component dependency 传播所需的稳定有向边。 */ +interface CompatibilityDependency { + readonly consumer: string; + readonly dependency: string; +} + +/** @returns Canonical Project 的 Component dependency edges。 */ +function componentDependencies(project: CanonicalProject): readonly CompatibilityDependency[] { + /** 三类 Component 统一为 subject identity。 */ + const components = [...project.commands, ...project.skills, ...project.agents]; + /** dependency edge 在返回前统一排序。 */ + const result: CompatibilityDependency[] = []; + for (const component of components) { + /** consumer 使用 canonical kind/id 组成稳定 subject。 */ + const consumer = `${component.kind}:${component.id}`; + for (const id of component.requires.skills) + result.push(Object.freeze({ consumer, dependency: `skill:${id}` })); + for (const id of component.requires.agents) + result.push(Object.freeze({ consumer, dependency: `agent:${id}` })); + } + return Object.freeze(result.sort((left, right) => compareCodePoints(left.consumer, right.consumer) + || compareCodePoints(left.dependency, right.dependency))); +} + +/** BuildSession 中集中执行覆盖、传播与最终 strictness 的兼容性 Registry。 */ +export class CompatibilityRegistry { + /** 当前 Project 用于 Component/metadata 完整覆盖。 */ + readonly #project: CanonicalProject; + /** 最终诊断出口。 */ + readonly #diagnostics: DiagnosticRegistry; + /** tuple key 到兼容性条目。 */ + readonly #compatibility = new Map(); + /** platform+field 到 metadata 条目。 */ + readonly #metadata = new Map(); + + /** @param options 当前 Canonical Project 与统一诊断。 */ + constructor(options: { readonly project: CanonicalProject; readonly diagnostics: DiagnosticRegistry }) { + this.#project = options.project; + this.#diagnostics = options.diagnostics; + } + + /** + * 为一个 Platform 加入已验证 base/contribution compatibility。 + * + * @param platform Platform ID。 + * @param inputs 任意完成顺序的输入集合。 + */ + addCompatibility(platform: string, inputs: readonly CompatibilityInput[]): void { + for (const input of inputs) { + /** 每条输入独立 snapshot 后才建立 tuple key。 */ + const entry = snapshotCompatibility(platform, input); + /** Platform 与 tuple 共同组成 Registry 唯一键。 */ + const key = `${platform}:${entryKey(entry)}`; + if (this.#compatibility.has(key)) + throw new TypeError(`Compatibility tuple "${key}" is duplicated.`); + this.#compatibility.set(key, entry); + } + } + + /** + * 为一个 Platform 加入 metadata dispositions。 + * + * @param platform Platform ID。 + * @param inputs Platform base metadata 结论。 + */ + addMetadata(platform: string, inputs: readonly MetadataDispositionInput[]): void { + for (const input of inputs) { + /** 每条 disposition 独立 snapshot 后才建立字段键。 */ + const entry = snapshotMetadata(platform, input); + /** Platform 与 metadata field 共同组成 Registry 唯一键。 */ + const key = `${platform}:${entry.field}`; + if (this.#metadata.has(key)) + throw new TypeError(`Metadata disposition "${key}" is duplicated.`); + this.#metadata.set(key, entry); + } + } + + /** + * 验证覆盖、cause graph 和依赖传播,并执行一次最终 strictness。 + * + * @param platforms 选中 Platform 与最终 strict 标记。 + * @returns 排序、冻结的兼容性和 metadata 报告集合。 + */ + finalize(platforms: readonly { readonly id: string; readonly strict: boolean }[]): { + readonly compatibility: readonly CompatibilityEntry[]; + readonly metadata: readonly MetadataDispositionEntry[]; + } { + /** 组件 subject 必须在每个平台恰好拥有 component tuple。 */ + const componentSubjects = [...this.#project.commands, ...this.#project.skills, ...this.#project.agents] + .map(component => `${component.kind}:${component.id}`) + .sort(compareCodePoints); + /** metadata 只覆盖当前配置实际出现的字段。 */ + const expectedMetadata = metadataFields(this.#project.metadata); + for (const platform of [...platforms].sort((left, right) => compareCodePoints(left.id, right.id))) { + for (const subject of componentSubjects) { + if (!this.#compatibility.has(`${platform.id}:${compatibilityTupleKey(subject, 'component')}`)) { + this.#diagnostics.report('compatibility', { + code: 'COMPATIBILITY_COMPONENT_MISSING', severity: 'error', message: `Platform "${platform.id}" did not report component compatibility for "${subject}".`, + }, { platform: platform.id }); + } + } + /** 当前 Platform 实际提交的 metadata 字段集合。 */ + const actualFields = [...this.#metadata.values()].filter(entry => entry.platform === platform.id).map(entry => entry.field); + for (const field of expectedMetadata) { + if (!actualFields.includes(field)) { + this.#diagnostics.report('compatibility', { + code: 'METADATA_DISPOSITION_MISSING', severity: 'error', message: `Platform "${platform.id}" did not report metadata field "${field}".`, + }, { platform: platform.id }); + } + } + for (const field of actualFields) { + if (!expectedMetadata.includes(field)) { + this.#diagnostics.report('compatibility', { + code: 'METADATA_DISPOSITION_UNUSED', severity: 'error', message: `Platform "${platform.id}" reported absent metadata field "${field}".`, + }, { platform: platform.id }); + } + } + } + /** cause refs 必须存在于同一 Platform 且形成无环图。 */ + for (const entry of this.#compatibility.values()) { + /** self 用于拒绝显式自引用。 */ + const self = entryKey(entry); + for (const cause of entry.causes ?? []) { + if (cause === self || !this.#compatibility.has(`${entry.platform}:${cause}`)) + throw new TypeError(`Compatibility cause "${cause}" is missing or self-referential.`); + } + } + /** 对每个平台的显式 cause graph 执行 DFS 循环检测。 */ + for (const platform of platforms) { + /** 当前平台 tuple key 到条目的局部索引。 */ + const entries = new Map([...this.#compatibility.values()] + .filter(entry => entry.platform === platform.id) + .map(entry => [entryKey(entry), entry])); + /** visiting/visited 分别表示 DFS 灰色和黑色节点。 */ + const visiting = new Set(); + /** 已完全验证的黑色节点无需重复遍历。 */ + const visited = new Set(); + /** 单节点 cause DFS。 */ + const visit = (key: string): void => { + if (visiting.has(key)) + throw new TypeError(`Compatibility causes contain a cycle at "${key}".`); + if (visited.has(key)) + return; + visiting.add(key); + for (const cause of entries.get(key)?.causes ?? []) + visit(cause); + visiting.delete(key); + visited.add(key); + }; + for (const key of [...entries.keys()].sort(compareCodePoints)) + visit(key); + } + /** Component dependency 只传播 component capability 的最差等级。 */ + const dependencies = componentDependencies(this.#project); + /** fixed-point 标记传播是否仍产生更差等级。 */ + let changed = true; + while (changed) { + changed = false; + for (const platform of platforms) { + for (const edge of dependencies) { + /** consumer tuple 定位当前 Platform 的依赖方。 */ + const consumerKey = `${platform.id}:${compatibilityTupleKey(edge.consumer, 'component')}`; + /** dependency tuple 定位当前 Platform 的被依赖方。 */ + const dependencyKey = `${platform.id}:${compatibilityTupleKey(edge.dependency, 'component')}`; + /** consumer 缺失由覆盖诊断负责,不在传播阶段合成。 */ + const consumer = this.#compatibility.get(consumerKey); + /** dependency 缺失同样不产生虚假传播条目。 */ + const dependency = this.#compatibility.get(dependencyKey); + if (consumer === undefined || dependency === undefined || LEVEL_WEIGHT[dependency.level] <= LEVEL_WEIGHT[consumer.level]) + continue; + /** 派生条目保留 consumer tuple 并加入依赖 cause。 */ + const cause = compatibilityTupleKey(edge.dependency, 'component'); + /** cause 集合去重排序后形成下一轮传播输入。 */ + const causes = [...new Set([...(consumer.causes ?? []), cause])].sort(compareCodePoints); + this.#compatibility.set(consumerKey, Object.freeze({ + ...consumer, + level: dependency.level, + reason: `Dependency "${edge.dependency}" has ${dependency.level} compatibility.`, + causes: Object.freeze(causes), + })); + changed = true; + } + } + } + /** strict enforcement 只观察最终传播完成的图。 */ + for (const platform of platforms) { + for (const entry of this.#compatibility.values()) { + if (entry.platform !== platform.id || (entry.level !== 'degraded' && entry.level !== 'unsupported')) + continue; + this.#diagnostics.report('compatibility', { + code: platform.strict ? 'COMPATIBILITY_STRICT_FAILURE' : 'COMPATIBILITY_RELAXED', + severity: platform.strict ? 'error' : 'warning', + message: `Platform "${platform.id}" reports ${entry.level} for ${entry.subject}/${entry.capability}.`, + }, { platform: platform.id }); + } + } + /** 最终数组使用明确 tuple 键排序。 */ + const compatibility = [...this.#compatibility.values()].sort((left, right) => compareCodePoints(left.platform, right.platform) + || compareCodePoints(left.subject, right.subject) || compareCodePoints(left.capability, right.capability)); + /** metadata 报告按 Platform/field 固定排序。 */ + const metadata = [...this.#metadata.values()].sort((left, right) => compareCodePoints(left.platform, right.platform) + || compareCodePoints(left.field, right.field)); + return Object.freeze({ compatibility: Object.freeze(compatibility), metadata: Object.freeze(metadata) }); + } +} diff --git a/packages/core/src/package/distributions.ts b/packages/core/src/package/distributions.ts new file mode 100644 index 0000000..46145a5 --- /dev/null +++ b/packages/core/src/package/distributions.ts @@ -0,0 +1,153 @@ +/** Core 集中验证并冻结 Platform Distribution。 */ +import type { + AssetRef, + AssetService, +} from '../contracts/services.js'; +import type { + DistributionPackageInput, + PackageAssetInput, + PackageAssetSnapshot, + PackageUnitSnapshot, +} from '../contracts/packages.js'; +import { AssetRegistry } from '../services/assets.js'; +import { dataArrayItems, dataObjectFields } from '../security/data-boundary.js'; +import { compareCodePoints, safeRelativePath, sourceCollisionKey } from '../security/path-policy.js'; + +/** Distribution 和 Platform ID 共用的 lowercase-kebab 规则。 */ +const STABLE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + +/** @returns 已验证的 Platform 或 Distribution ID。 */ +function stableId(value: unknown, label: string): string { + if (typeof value !== 'string' || !STABLE_ID.test(value)) + throw new TypeError(`${label} must use lowercase kebab-case.`); + return value; +} + +/** + * 在一个 Distribution 中登记路径并拒绝 exact/case/NFC/prefix 冲突。 + * + * @param paths 当前 Distribution 路径闭包。 + * @param value 新路径候选。 + * @returns 安全 package-relative 路径。 + */ +function reservePath(paths: Map, value: unknown): string { + /** 路径语法验证不会静默 normalize Integration 输入。 */ + const safe = safeRelativePath(value); + /** 最严格目标文件系统使用的 collision key。 */ + const key = sourceCollisionKey(safe); + for (const [reservedKey, reservedPath] of paths) { + if (key === reservedKey || key.startsWith(`${reservedKey}/`) || reservedKey.startsWith(`${key}/`)) + throw new TypeError(`Distribution path "${safe}" collides with "${reservedPath}".`); + } + paths.set(key, safe); + return safe; +} + +/** + * 从已验证 primary 和当前 callback 新签发 ref 建立 Distribution Unit。 + * + * @param options Platform、primary、Distribution 输入与授权 Registry。 + * @returns 保留 inherited owner/mode/hash/origin 的 frozen Unit。 + */ +export function createDistributionPackage(options: { + readonly platform: string; + readonly primary: PackageUnitSnapshot; + readonly input: DistributionPackageInput; + readonly assets: AssetRegistry; + readonly issued: (asset: AssetRef) => boolean; +}): PackageUnitSnapshot { + /** 当前 Platform identity 同时约束 primary 和最终 Unit。 */ + const platform = stableId(options.platform, 'Platform id'); + if (options.primary.platform !== platform || options.primary.role !== 'primary') + throw new TypeError('Distribution primary must be the current Platform validated primary Unit.'); + /** Distribution 输入只有 id/type/assets 三个字段。 */ + const fields = dataObjectFields(options.input, new Set(['id', 'type', 'assets']), 'Distribution Package'); + /** Distribution ID 决定最终两级输出 root。 */ + const id = stableId(fields.id?.value, 'Distribution Package id'); + if (fields.type?.value !== 'marketplace') + throw new TypeError('Distribution Package type must be marketplace.'); + /** primary Unit ID 与 Distribution ID 共用目标 Platform namespace。 */ + if (id === options.primary.id) + throw new TypeError('Distribution Package id must differ from the primary Package id.'); + /** 只接受稠密的显式 asset mappings。 */ + const inputs = dataArrayItems(fields.assets?.value, 'Distribution Package assets') as readonly PackageAssetInput[]; + /** primaryRef identity 到继承 snapshot,不能按可伪造公开字段匹配。 */ + const inherited = new Map(); + for (const asset of options.primary.assets) + inherited.set(asset.asset, asset); + /** 所有输出 path 在 Distribution 内共享完整冲突域。 */ + const paths = new Map(); + /** snapshots 保留 inherited issuer owner。 */ + const snapshots: PackageAssetSnapshot[] = []; + /** 新签发 ref 必须属于当前 Platform owner。 */ + const owner = `platform:${platform}`; + for (const [index, input] of inputs.entries()) { + /** 单个 mapping 不允许隐藏来源或 serializer 字段。 */ + const mapping = dataObjectFields(input, new Set(['path', 'asset']), `Distribution Package assets[${index}]`); + /** path 与 ref 分别从已验证 descriptor 读取。 */ + const outputPath = reservePath(paths, mapping.path?.value); + /** AssetRef 真实性只由后续 identity lookup 判断。 */ + const asset = mapping.asset?.value as AssetRef; + /** inherited ref 可以重映射路径,但必须来自当前 primary 原始 identity。 */ + const primary = inherited.get(asset); + if (primary !== undefined) { + options.assets.describe(owner, asset); + snapshots.push(Object.freeze({ path: outputPath, owner: primary.owner, asset })); + continue; + } + /** 新增 ref 必须由当前 createDistributions callback scope 新签发。 */ + if (!options.issued(asset)) + throw new TypeError('Distribution Asset must be inherited from primary or issued during the current callback.'); + /** issued scope 通过后仍复核 Registry owner/session。 */ + const record = options.assets.describe(owner, asset); + if (record.owner !== owner) + throw new TypeError('Distribution callback additions must be issued by the current Platform.'); + snapshots.push(Object.freeze({ path: outputPath, owner, asset })); + } + return Object.freeze({ + platform, + id, + type: 'marketplace', + role: 'distribution', + assets: Object.freeze(snapshots.sort((left, right) => compareCodePoints(left.path, right.path))), + compatibility: options.primary.compatibility, + metadata: options.primary.metadata, + }); +} + +/** + * 在 Core 管理的一次性 Asset scope 内运行 Platform Distribution callback。 + * + * @param options 当前 Platform、validated primary、Registry 与 callback。 + * @returns ID 唯一、稳定排序的 Distribution Units。 + */ +export async function collectDistributionPackages(options: { + readonly platform: string; + readonly primary: PackageUnitSnapshot; + readonly assets: AssetRegistry; + readonly create: (assets: AssetService) => readonly DistributionPackageInput[] | Promise; +}): Promise { + /** callback 只在 issuance scope active 期间获得 AssetService。 */ + const scope = options.assets.issuanceScope(`platform:${stableId(options.platform, 'Platform id')}`); + /** outputs 在 finally 关闭 scope 前由 callback 完整返回。 */ + let outputs: readonly DistributionPackageInput[]; + try { + outputs = await options.create(scope.service); + } finally { + scope.close(); + } + /** callback 返回数组也必须是稠密 data array。 */ + const inputs = dataArrayItems(outputs, 'Distribution Packages') as readonly DistributionPackageInput[]; + /** 每个 output 独立通过 Distribution Registry 授权和路径校验。 */ + const units = inputs.map(input => createDistributionPackage({ + platform: options.platform, + primary: options.primary, + input, + assets: options.assets, + issued: scope.includes, + })); + /** 当前 Platform primary/distribution namespace 内的 Unit ID 必须唯一。 */ + if (new Set(units.map(unit => unit.id)).size !== units.length) + throw new TypeError('Distribution Package ids must be unique.'); + return Object.freeze(units.sort((left, right) => compareCodePoints(left.id, right.id))); +} diff --git a/packages/core/src/package/documents.ts b/packages/core/src/package/documents.ts new file mode 100644 index 0000000..e6c8d86 --- /dev/null +++ b/packages/core/src/package/documents.ts @@ -0,0 +1,87 @@ +/** Core 确定性编解码结构化 Package Document。 */ +import { stringify as stringifyToml } from 'smol-toml'; +import { stringify as stringifyYaml } from 'yaml'; +import type { + JsonObject, + JsonValue, +} from '../contracts/common.js'; +import type { PackageDocumentSnapshot } from '../contracts/packages.js'; +import { snapshotJson } from '../security/json-snapshot.js'; + +/** Frontmatter Document 的唯一结构化 schema。 */ +interface FrontmatterDocumentValue extends JsonObject { + readonly frontmatter: JsonObject; + readonly body: string; +} + +/** @returns JSON object 是否不包含任何字段。 */ +function emptyObject(value: JsonValue): boolean { + return typeof value === 'object' && value !== null && !Array.isArray(value) && Object.keys(value).length === 0; +} + +/** + * 验证 frontmatter codec 的精确根结构。 + * + * @param value 已完成 JSON snapshot 的 Document 值。 + * @returns 只含 frontmatter/body 的可序列化结构。 + */ +function frontmatterValue(value: JsonValue): FrontmatterDocumentValue { + if (typeof value !== 'object' || value === null || Array.isArray(value) + || Object.keys(value).sort().join(',') !== 'body,frontmatter') { + throw new TypeError('Frontmatter Document value must contain exactly frontmatter and body.'); + } + /** 两个固定字段在严格 JSON snapshot 上读取不会执行行为。 */ + const input = value as JsonObject; + if (typeof input.body !== 'string' || typeof input.frontmatter !== 'object' + || input.frontmatter === null || Array.isArray(input.frontmatter)) { + throw new TypeError('Frontmatter Document requires a JSON object frontmatter and string body.'); + } + return input as FrontmatterDocumentValue; +} + +/** + * 判断 omit-if-empty Document 当前是否为空。 + * + * @param document 已验证 Document snapshot。 + * @returns 空 object 或空 frontmatter+body 为 true。 + */ +export function documentIsEmpty(document: PackageDocumentSnapshot): boolean { + if (document.format !== 'frontmatter') + return emptyObject(document.value); + /** Frontmatter 空值要求头部无字段且正文为空。 */ + const value = frontmatterValue(document.value); + return emptyObject(value.frontmatter) && value.body.trim().length === 0; +} + +/** + * 使用 Core 固定 codec 产生确定性 UTF-8 Document 字节。 + * + * @param document 已验证且冻结的 Package Document。 + * @returns 单个尾随换行、无环境信息的稳定字节。 + */ +export function encodePackageDocument(document: PackageDocumentSnapshot): Uint8Array { + /** codec 再次建立 JSON snapshot,防止内部调用方绕过 Package Registry。 */ + const value = snapshotJson(document.value, `Document ${document.id}`); + /** 文本只由选中 codec 的确定性结果赋值一次。 */ + let text: string; + if (document.format === 'json') { + text = `${JSON.stringify(value, null, 2)}\n`; + } else if (document.format === 'yaml') { + text = `${stringifyYaml(value, { lineWidth: 0, aliasDuplicateObjects: false }).trimEnd()}\n`; + } else if (document.format === 'toml') { + if (typeof value !== 'object' || value === null || Array.isArray(value)) + throw new TypeError('TOML Document root must be a JSON object.'); + try { + text = `${stringifyToml(value as never).trimEnd()}\n`; + } catch { + throw new TypeError('TOML Document contains a value that cannot be represented losslessly.'); + } + } else { + /** Frontmatter 使用稳定 YAML head 和精确修整后的正文。 */ + const input = frontmatterValue(value); + /** YAML header 独立生成后嵌入固定分隔符。 */ + const head = stringifyYaml(input.frontmatter, { lineWidth: 0, aliasDuplicateObjects: false }).trimEnd(); + text = `---\n${head}\n---\n${input.body.trim()}\n`; + } + return new TextEncoder().encode(text); +} diff --git a/packages/core/src/package/json-snapshot.ts b/packages/core/src/package/json-snapshot.ts new file mode 100644 index 0000000..0da6a10 --- /dev/null +++ b/packages/core/src/package/json-snapshot.ts @@ -0,0 +1,76 @@ +import type { + DocumentFieldPath, + JsonObject, + JsonValue, +} from '../contracts/common.js'; +import { dataArrayItems } from '../security/data-boundary.js'; +import { compareCodePoints } from '../security/path-policy.js'; + +/** + * 验证并复制非空 Document 字段路径。 + * + * @param value 未受信任路径。 + * @param label 诊断标签。 + * @returns 不含控制字符且冻结的非空字段 tuple。 + */ +export function snapshotFieldPath(value: unknown, label = 'Document field path'): DocumentFieldPath { + /** Document paths use the same dense, descriptor-only boundary as every Integration array. */ + const items = dataArrayItems(value, label); + if (items.length === 0 + || items.some(segment => typeof segment !== 'string' || segment.length === 0 || /[\0\r\n\t]/u.test(segment))) { + throw new TypeError(`${label} must be a non-empty array of stable field names.`); + } + return items as unknown as DocumentFieldPath; +} + +/** @returns 字段路径不会因分隔字符内容产生歧义的内部键。 */ +export function documentFieldKey(path: DocumentFieldPath): string { + return JSON.stringify(path); +} + +/** + * 检查字段父链存在且最终字段尚未出现。 + * + * @param value 当前 Document 根值。 + * @param fieldPath 待贡献的精确字段路径。 + * @returns 当前路径是 add-only 空位时为 true。 + */ +export function documentFieldAvailable(value: JsonValue, fieldPath: DocumentFieldPath): boolean { + /** current 沿既有父链逐段进入。 */ + let current: JsonValue = value; + for (const segment of fieldPath.slice(0, -1)) { + if (current === null || typeof current !== 'object' || Array.isArray(current) || !Object.hasOwn(current, segment)) + return false; + current = (current as JsonObject)[segment]!; + } + if (current === null || typeof current !== 'object' || Array.isArray(current)) + return false; + return !Object.hasOwn(current, fieldPath.at(-1)!); +} + +/** + * 通过逐层复制向不可变 JSON 新增一个精确字段。 + * + * @param value 已证明字段空缺的 Document 值。 + * @param fieldPath 精确字段路径。 + * @param addition 已冻结的新增 JSON。 + * @returns 保持全部原字段且新增目标字段的冻结值。 + */ +export function addDocumentField(value: JsonValue, fieldPath: DocumentFieldPath, addition: JsonValue): JsonValue { + /** 当前层必然是字段父链上的 JSON object。 */ + const object = value as JsonObject; + /** head 是当前层字段,tail 是剩余路径。 */ + const [head, ...tail] = fieldPath; + /** 原字段先映射到新容器,路径字段递归替换为复制结果。 */ + const entries: [string, JsonValue][] = Object.entries(object).map(([field, child]) => [ + field, + field === head && tail.length > 0 ? addDocumentField(child, tail as unknown as DocumentFieldPath, addition) : child, + ]); + if (tail.length === 0) + entries.push([head, addition]); + /** 每层对象重新按 code point 排序并定义只读 data property。 */ + const result: Record = {}; + for (const [field, child] of entries.sort(([left], [right]) => compareCodePoints(left, right))) + Object.defineProperty(result, field, { value: child, enumerable: true, configurable: false, writable: false }); + return Object.freeze(result); +} diff --git a/packages/core/src/package/registry.ts b/packages/core/src/package/registry.ts new file mode 100644 index 0000000..879ba96 --- /dev/null +++ b/packages/core/src/package/registry.ts @@ -0,0 +1,599 @@ +import type { AssetRef } from '../contracts/services.js'; +/** Core 集中拥有 Platform base Package 与 Contribution merge。 */ +import type { + CompatibilityInput, + ContributedPackageComponent, + MetadataDispositionInput, + PackageComponentOrigin, + PackageContribution, + PlatformDeliveryType, +} from '../contracts/integrations.js'; +import type { DocumentFieldPath, JsonObject, JsonValue } from '../contracts/common.js'; +import type { + MergedPackageSnapshot, + PackageAssetInput, + PackageAssetSnapshot, + PackageDocumentInput, + PackageDocumentSnapshot, + PlatformFinalizationFieldContribution, + PackageUnitSnapshot, + PlatformBasePackageSnapshot, + PlatformPackageInput, + PrimaryPackageInput, +} from '../contracts/packages.js'; +import { AssetRegistry } from '../services/assets.js'; +import { dataArrayItems, dataObjectFields } from '../security/data-boundary.js'; +import { snapshotJsonWithObjectGuard } from '../security/json-snapshot.js'; +import { compareCodePoints, safeRelativePath, sourceCollisionKey } from '../security/path-policy.js'; +import { snapshotCompatibility, snapshotMetadata } from './compatibility.js'; +import { documentIsEmpty, encodePackageDocument } from './documents.js'; +import { + addDocumentField, + documentFieldAvailable, + documentFieldKey, + snapshotFieldPath, +} from './json-snapshot.js'; + +/** Package、Document 与 Unit ID 共用的稳定标识规则。 */ +const STABLE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + +/** 无序 Contribution 的 owner-bound 内部输入。 */ +export interface OwnedPackageContribution { + readonly owner: string; + readonly contribution: PackageContribution; + readonly subjects?: readonly { readonly subject: string; readonly capabilities: readonly string[] }[]; +} + +/** + * 验证对象仅含允许的 data fields。 + * + * @param value 未受信任对象。 + * @param allowed 精确字段集合。 + * @param label 诊断标签。 + * @returns 不执行 getter 的字段描述符。 + */ +function fields(value: unknown, allowed: ReadonlySet, label: string): Record { + return dataObjectFields(value, allowed, label); +} + +/** + * 复制 Package 层严格 JSON,并拒绝把当前 Session 的 capability identity 当成数据。 + * + * SourceRef/AssetRef 只能通过专用 Package Asset mapping 传递;若进入 Document 或 + * opaque Component JSON,它们会退化成看似普通的 kind/id 对象并产出损坏配置。 + */ +function snapshotPackageJson(value: unknown, label: string, assets: AssetRegistry): JsonValue { + return snapshotJsonWithObjectGuard(value, label, (candidate, path) => { + if (assets.isCapabilityReference(candidate)) + throw new TypeError(`${path} must not contain Core capability references.`); + }); +} + +/** @returns 未知文本是否为规范 package/document ID。 */ +function stableId(value: unknown, label: string): string { + if (typeof value !== 'string' || !STABLE_ID.test(value)) + throw new TypeError(`${label} must use lowercase kebab-case.`); + return value; +} + +/** + * 在单个 Package namespace 内登记路径并拒绝 exact/case/NFC 碰撞。 + * + * @param paths 已占用 collision key 到原始路径。 + * @param value 新路径候选。 + * @param label 路径角色。 + * @returns 验证后的 package-relative POSIX 路径。 + */ +function reservePath(paths: Map, value: unknown, label: string): string { + /** 首先规范化并验证 package-relative POSIX path。 */ + const safe = safeRelativePath(value); + /** collision key 同时折叠大小写与 Unicode 规范化。 */ + const key = sourceCollisionKey(safe); + /** 已占用的原始路径用于稳定冲突诊断。 */ + const existing = paths.get(key); + if (existing !== undefined) + throw new TypeError(`${label} path "${safe}" collides with "${existing}".`); + /** 文件路径不能同时充当另一个文件的祖先目录。 */ + for (const [reservedKey, reservedPath] of paths) { + if (key.startsWith(`${reservedKey}/`) || reservedKey.startsWith(`${key}/`)) + throw new TypeError(`${label} path "${safe}" has a file/directory conflict with "${reservedPath}".`); + } + paths.set(key, safe); + return safe; +} + +/** + * 复制并验证 Document extension points。 + * + * @param value Document 当前值。 + * @param input 未受信任 paths。 + * @returns 唯一、排序且全部指向当前空位的 paths。 + */ +function documentPoints( + value: JsonValue, + input: unknown, + label: 'extension' | 'finalization', +): readonly DocumentFieldPath[] { + /** 两类 point 都必须越过稠密 data array 边界。 */ + const items = dataArrayItems(input, `Document ${label}Points`); + /** path key 到精确 tuple,用于拒绝重复声明。 */ + const paths = new Map(); + for (const [index, candidate] of items.entries()) { + /** 每条 path 复制为非空字段 tuple。 */ + const path = snapshotFieldPath(candidate, `Document ${label}Points[${index}]`); + /** JSON tuple key 避免字段名中的分隔符产生歧义。 */ + const key = documentFieldKey(path); + if (paths.has(key)) + throw new TypeError(`Document ${label} point ${key} is duplicated.`); + if (!documentFieldAvailable(value, path)) + throw new TypeError(`Document ${label} point ${key} must target an exact empty field.`); + paths.set(key, path); + } + return Object.freeze([...paths.values()].sort((left, right) => compareCodePoints(documentFieldKey(left), documentFieldKey(right)))); +} + +/** + * 复制一个 Platform base Document。 + * + * @param input Platform 返回的结构化 Document。 + * @param paths 与 base Asset 共享的输出路径索引。 + * @returns 冻结且可安全交给全部 Contributor 的 Document。 + */ +function documentSnapshot( + input: PackageDocumentInput, + paths: Map, + assets: AssetRegistry, +): PackageDocumentSnapshot { + /** Document 顶层只允许规范字段。 */ + const descriptor = fields(input, new Set(['id', 'path', 'format', 'value', 'emission', 'extensionPoints', 'finalizationPoints']), 'Package Document'); + /** Document ID 与路径分别建立逻辑和物理身份。 */ + const id = stableId(descriptor.id?.value, 'Document id'); + /** Document path 立即进入共享 Package collision domain。 */ + const documentPath = reservePath(paths, descriptor.path?.value, `Document "${id}"`); + /** format 决定后续唯一 Core codec。 */ + const format = descriptor.format?.value; + if (format !== 'json' && format !== 'yaml' && format !== 'toml' && format !== 'frontmatter') + throw new TypeError(`Document "${id}" format is invalid.`); + /** value 立即复制为不可变严格 JSON。 */ + const value = snapshotPackageJson(descriptor.value?.value, `Document ${id}`, assets); + /** 未声明 emission 时 Document 必须生成。 */ + const emission = descriptor.emission?.value ?? 'required'; + if (emission !== 'required' && emission !== 'omit-if-empty') + throw new TypeError(`Document "${id}" emission is invalid.`); + /** 完整 snapshot 是 Contributor 唯一可见的 Document 形态。 */ + const extensions = documentPoints(value, descriptor.extensionPoints?.value, 'extension'); + const finalization = documentPoints(value, descriptor.finalizationPoints?.value ?? [], 'finalization'); + /** 两类点位都拥有同一 Document 空字段命名空间,禁止绕开 owner isolation。 */ + const extensionKeys = new Set(extensions.map(documentFieldKey)); + for (const point of finalization) { + if (extensionKeys.has(documentFieldKey(point))) + throw new TypeError(`Document finalization point ${documentFieldKey(point)} overlaps an extension point.`); + } + const snapshot = Object.freeze({ + id, + path: documentPath, + format, + value, + emission, + extensionPoints: extensions, + finalizationPoints: finalization, + }); + /** codec 可表达性属于 base Package 边界,不能推迟到 finalization。 */ + encodePackageDocument(snapshot); + return snapshot; +} + +/** + * 复制一条 owner-authorized Package Asset mapping。 + * + * @param owner 当前 Platform、Extension 或 Framework owner。 + * @param input 未受信任 path/ref mapping。 + * @param assets 当前 BuildSession Asset Registry。 + * @param paths 当前 Package 路径索引。 + * @returns 保留真实 issuer owner 的冻结 snapshot。 + */ +function assetSnapshot( + owner: string, + input: PackageAssetInput, + assets: AssetRegistry, + paths: Map, +): PackageAssetSnapshot { + /** Asset mapping 只允许 package path 和不透明 ref。 */ + const descriptor = fields(input, new Set(['path', 'asset']), 'Package Asset'); + /** Package path 在读取 Asset metadata 前先完成冲突检查。 */ + const assetPath = reservePath(paths, descriptor.path?.value, 'Asset'); + /** describe 同时验证 ref identity、owner grant 与 BuildSession。 */ + const asset = descriptor.asset?.value as AssetRef; + /** record owner 是真实 issuer,不能由 Package owner 覆盖。 */ + const record = assets.describe(owner, asset); + return Object.freeze({ path: assetPath, owner: record.owner, asset }); +} + +/** + * 从 Platform createPackage 输出建立 immutable base Package。 + * + * @param platform 当前 Platform ID。 + * @param input createPackage 原始输出。 + * @param assets 当前 Session Asset Registry。 + * @returns 所有 Contributor 共享的唯一 frozen base snapshot。 + */ +export function createBasePackage( + platform: string, + input: PlatformPackageInput, + assets: AssetRegistry, +): PlatformBasePackageSnapshot { + /** Platform Package 顶层字段在任何数组元素执行前完成检查。 */ + const descriptor = fields(input, new Set(['documents', 'assets', 'compatibility', 'metadata']), 'Platform Package'); + /** 四组输入分别建立稠密数组边界。 */ + const documentInputs = dataArrayItems(descriptor.documents?.value, 'Platform Package documents') as readonly PackageDocumentInput[]; + /** Asset 输入不能通过自定义数组属性携带隐藏语义。 */ + const assetInputs = dataArrayItems(descriptor.assets?.value, 'Platform Package assets') as readonly PackageAssetInput[]; + /** compatibility 输入复制由专用 snapshot 完成。 */ + const compatibilityInputs = dataArrayItems(descriptor.compatibility?.value, 'Platform Package compatibility') as readonly CompatibilityInput[]; + /** metadata 输入复制由专用 snapshot 完成。 */ + const metadataInputs = dataArrayItems(descriptor.metadata?.value, 'Platform Package metadata') as readonly MetadataDispositionInput[]; + /** Platform owner 由调用上下文绑定。 */ + const owner = `platform:${stableId(platform, 'Platform id')}`; + /** Document 和 Asset 共享同一个 package path collision domain。 */ + const paths = new Map(); + /** Document map 顺序不进入最终 snapshot。 */ + const documents = documentInputs.map(document => documentSnapshot(document, paths, assets)); + if (new Set(documents.map(document => document.id)).size !== documents.length) + throw new TypeError('Platform Package contains duplicate Document ids.'); + /** Base Assets 逐条校验 issuer/grant 并保留真实 owner。 */ + const mappedAssets = assetInputs.map((asset) => { + /** Platform Package 消费的外部 issuer ref 显式获得后续继承授权。 */ + const snapshot = assetSnapshot(owner, asset, assets, paths); + assets.grant(snapshot.owner, owner, snapshot.asset); + return snapshot; + }); + /** compatibility/metadata 在进入 base snapshot 时完成结构化验证但不绑定最终 report array。 */ + const compatibility = compatibilityInputs + .map((entry) => { + /** base snapshot 不重复保留当前已知 Platform ID。 */ + const { platform: _platform, ...snapshot } = snapshotCompatibility(platform, entry); + return Object.freeze(snapshot); + }); + /** metadata 使用与 compatibility 相同的去 Platform 身份 snapshot。 */ + const metadata = metadataInputs + .map((entry) => { + /** base snapshot 不重复保留当前已知 Platform ID。 */ + const { platform: _platform, ...snapshot } = snapshotMetadata(platform, entry); + return Object.freeze(snapshot); + }); + return Object.freeze({ + documents: Object.freeze(documents.sort((left, right) => compareCodePoints(left.id, right.id))), + assets: Object.freeze(mappedAssets.sort((left, right) => compareCodePoints(left.path, right.path))), + compatibility: Object.freeze(compatibility.sort((left, right) => compareCodePoints(left.subject, right.subject) + || compareCodePoints(left.capability, right.capability))), + metadata: Object.freeze(metadata.sort((left, right) => compareCodePoints(left.field, right.field))), + }); +} + +/** + * 校验 Contribution 对 validated subjects 的精确 compatibility 覆盖。 + * + * @param owner Contribution owner。 + * @param compatibility 已验证输入。 + * @param subjects Extension validate 声明的覆盖合同。 + */ +function validateSubjectCoverage( + owner: string, + compatibility: readonly CompatibilityInput[], + subjects: OwnedPackageContribution['subjects'], +): void { + if (subjects === undefined) + return; + /** 实际 tuple 集必须至少精确覆盖每个声明 tuple 一次。 */ + const actual = new Set(compatibility.map(entry => `${entry.subject}#${entry.capability}`)); + if (actual.size !== compatibility.length) + throw new TypeError(`Contribution "${owner}" contains duplicate compatibility tuples.`); + for (const subject of subjects) { + for (const capability of subject.capabilities) { + /** validate 声明的 tuple 必须由 Contribution 精确覆盖。 */ + const key = `${subject.subject}#${capability}`; + if (!actual.has(key)) + throw new TypeError(`Contribution "${owner}" does not cover "${key}".`); + } + } +} + +/** @returns Extension subject 是否使用其 validate 合同允许的稳定语法。 */ +function componentSubject(value: unknown, label: string): string { + if (typeof value !== 'string' || !/^[a-z0-9]+(?:[-.:/][a-z0-9]+)*$/u.test(value)) + throw new TypeError(`${label} must be a stable lowercase identifier.`); + return value; +} + +/** 按 strict JSON snapshot 的稳定编码建立不会依赖输入数组顺序的排序键。 */ +function componentValueKey(value: JsonObject): string { + return JSON.stringify(value); +} + +/** + * 验证 finalization field 引用的 Component origins 属于当前 merged Package。 + * + * object identity 是安全边界;metadata 同形、跨 Package/Session 的 origin 都不会 + * 命中这个 set。T02 的 AssetService 使用同一组 origin,因此 Document 与 Bytes + * Asset 的 provenance 规则完全一致。 + */ +function finalizationOrigins( + value: unknown, + components: readonly ContributedPackageComponent[], + label: string, +): readonly PackageComponentOrigin[] | undefined { + if (value === undefined) + return undefined; + const inputs = dataArrayItems(value, `${label} componentOrigins`); + const available = new Set(components.map(component => component.origin)); + const result = inputs.map((origin, index) => { + if (typeof origin !== 'object' || origin === null || !available.has(origin as PackageComponentOrigin)) + throw new TypeError(`${label} componentOrigins[${index}] is not authorized for the current Package.`); + return origin as PackageComponentOrigin; + }); + /** 以 Core-signed identity 去重,避免同一 payload 多次出现时污染 provenance。 */ + return Object.freeze([...new Set(result)].sort((left, right) => compareCodePoints(left.owner, right.owner) + || compareCodePoints(left.subject, right.subject))); +} + +/** + * 复制并验证一条 opaque Platform Component contribution。 + * + * 该函数故意不读取 value 的字段;identity、schema、namespace 与 rendering 完全由 + * 对应 Platform 在 finalization 阶段拥有。 + */ +function componentSnapshot( + platform: string, + assets: AssetRegistry, + owner: string, + input: unknown, + subjects: OwnedPackageContribution['subjects'], + index: number, +): ContributedPackageComponent { + if (subjects === undefined) + throw new TypeError(`Contribution "${owner}" cannot provide Components without validated Extension subjects.`); + const descriptor = fields(input, new Set(['subject', 'value']), `Contribution "${owner}" components[${index}]`); + const subject = componentSubject(descriptor.subject?.value, 'Contribution Component subject'); + if (!subjects.some(candidate => candidate.subject === subject)) + throw new TypeError(`Contribution "${owner}" Component subject "${subject}" was not declared by Extension validation.`); + const value = snapshotPackageJson( + descriptor.value?.value, + `Contribution ${owner} Component ${subject}`, + assets, + ); + if (value === null || typeof value !== 'object' || Array.isArray(value)) + throw new TypeError(`Contribution "${owner}" Component value must be a JSON object.`); + return Object.freeze({ + value: value as JsonObject, + origin: assets.issueComponentOrigin(platform, owner, subject), + }); +} + +/** + * 集中、无序地合并 Framework/Extension Contributions。 + * + * @param platform 当前 Platform ID。 + * @param base 所有 Contributor 读取的同一 base snapshot。 + * @param contributions 任意配置或完成顺序的 owner-bound outputs。 + * @param assets 当前 Session Asset Registry。 + * @returns 与输入顺序无关的 immutable merged Package。 + */ +export function mergePackageContributions( + platform: string, + base: PlatformBasePackageSnapshot, + contributions: readonly OwnedPackageContribution[], + assets: AssetRegistry, +): MergedPackageSnapshot { + stableId(platform, 'Platform id'); + /** owner 排序发生在任何 merge 前,确保错误与结果不受 completion order 影响。 */ + const ordered = [...contributions].sort((left, right) => compareCodePoints(left.owner, right.owner)); + if (new Set(ordered.map(item => item.owner)).size !== ordered.length) + throw new TypeError('A Package can receive at most one Contribution from each owner.'); + /** base 路径与扩展点先投影到可变的 Core 私有合并状态。 */ + const paths = new Map(); + for (const document of base.documents) + reservePath(paths, document.path, `Document "${document.id}"`); + for (const asset of base.assets) + reservePath(paths, asset.path, 'Base Asset'); + /** Document ID 索引承载逐层 copy-on-write 的合并结果。 */ + const documents = new Map(base.documents.map(document => [document.id, document])); + /** exact extension point 索引拒绝模糊或深合并语义。 */ + const points = new Map(); + for (const document of base.documents) { + for (const point of document.extensionPoints) + points.set(`${document.id}:${documentFieldKey(point)}`, Object.freeze({ document: document.id, path: point })); + } + /** claims 防止两个 owner 同时占用相同 exact extension point。 */ + const claims = new Map(); + /** Base Asset 已自动继承进入集中合并集合。 */ + const mappedAssets: PackageAssetSnapshot[] = [...base.assets]; + /** Base compatibility 与 Contribution tuple 共享碰撞域。 */ + const compatibility: CompatibilityInput[] = [...base.compatibility]; + /** Component payload 只在 merge 后、Platform finalization 前可见。 */ + const components: ContributedPackageComponent[] = []; + for (const item of ordered) { + /** 单个 Contribution 只允许既有 add-only 输入和 opaque components envelope。 */ + const descriptor = fields(item.contribution, new Set(['components', 'documentFields', 'assets', 'compatibility']), `Contribution "${item.owner}"`); + /** components 必须仍是 dense data array,内部只允许 subject/value JSON object。 */ + const rawComponents = dataArrayItems(descriptor.components?.value ?? [], `Contribution "${item.owner}" components`); + /** 缺省 documentFields 等价于空 add-only 集合。 */ + const rawFields = dataArrayItems(descriptor.documentFields?.value ?? [], `Contribution "${item.owner}" documentFields`); + /** 缺省 assets 等价于空 add-only 集合。 */ + const rawAssets = dataArrayItems(descriptor.assets?.value ?? [], `Contribution "${item.owner}" assets`); + /** compatibility 是必填覆盖合同,不能缺省。 */ + const rawCompatibility = dataArrayItems(descriptor.compatibility?.value, `Contribution "${item.owner}" compatibility`); + /** Compatibility 完成 snapshot 后再执行 subject coverage。 */ + const contributionCompatibility = (rawCompatibility as readonly CompatibilityInput[]).map((entry) => { + /** merged snapshot 不重复保留当前已知 Platform ID。 */ + const { platform: _platform, ...snapshot } = snapshotCompatibility(platform, entry); + return Object.freeze(snapshot); + }); + validateSubjectCoverage(item.owner, contributionCompatibility, item.subjects); + compatibility.push(...contributionCompatibility); + for (const [index, raw] of rawComponents.entries()) + components.push(componentSnapshot(platform, assets, item.owner, raw, item.subjects, index)); + for (const [index, raw] of [...rawFields].entries()) { + /** 单个字段贡献只能声明 document/path/value。 */ + const field = fields(raw, new Set(['document', 'path', 'value']), `Contribution "${item.owner}" documentFields[${index}]`); + /** Document ID 必须命中 base 声明。 */ + const document = stableId(field.document?.value, 'Contribution Document id'); + /** 精确字段 tuple 与 base extension point 使用同一规范。 */ + const path = snapshotFieldPath(field.path?.value, 'Contribution Document field path'); + /** 合成无歧义 extension point lookup key。 */ + const key = `${document}:${documentFieldKey(path)}`; + /** point 缺失表示 Platform 未公开该写入位置。 */ + const point = points.get(key); + if (point === undefined) + throw new TypeError(`Contribution "${item.owner}" targets undeclared extension point ${key}.`); + /** claim owner 用于检测两个无序 Contributor 的竞争。 */ + const existingOwner = claims.get(key); + if (existingOwner !== undefined) + throw new TypeError(`Document extension point ${key} is claimed by both ${existingOwner} and ${item.owner}.`); + /** current 始终是前一次 copy-on-write 的 frozen snapshot。 */ + const current = documents.get(document)!; + /** addition 必须先复制成严格 JSON。 */ + const addition = snapshotPackageJson(field.value?.value, `Contribution ${item.owner} field ${key}`, assets); + documents.set(document, Object.freeze({ ...current, value: addDocumentField(current.value, path, addition) })); + claims.set(key, item.owner); + } + for (const asset of rawAssets as readonly PackageAssetInput[]) { + /** Contribution 校验使用 contributor owner,随后授权目标 Platform 继承。 */ + const snapshot = assetSnapshot(item.owner, asset, assets, paths); + assets.grant(snapshot.owner, `platform:${platform}`, snapshot.asset); + mappedAssets.push(snapshot); + } + } + /** duplicate compatibility tuple 不能由后写覆盖。 */ + const tuples = compatibility.map(entry => `${entry.subject}#${entry.capability}`); + if (new Set(tuples).size !== tuples.length) + throw new TypeError('Merged Package contains duplicate compatibility tuples.'); + return Object.freeze({ + documents: Object.freeze([...documents.values()].sort((left, right) => compareCodePoints(left.id, right.id))), + assets: Object.freeze(mappedAssets.sort((left, right) => compareCodePoints(left.path, right.path))), + compatibility: Object.freeze(compatibility.sort((left, right) => compareCodePoints(left.subject, right.subject) + || compareCodePoints(left.capability, right.capability))), + metadata: base.metadata, + components: Object.freeze(components.sort((left, right) => compareCodePoints(left.origin.owner, right.origin.owner) + || compareCodePoints(left.origin.subject, right.origin.subject) + || compareCodePoints(componentValueKey(left.value), componentValueKey(right.value)))), + }); +} + +/** + * 将 Platform finalization fields 应用于 Platform 自己已预留的 Document 空字段。 + * + * Extension merge 与 Platform finalization 是两个不可重叠的 add-only 命名空间; + * 因此该步骤不能替换 Document、深合并或触及 extension point。 + */ +function finalizeDocuments( + documents: readonly PackageDocumentSnapshot[], + input: readonly PlatformFinalizationFieldContribution[], + components: readonly ContributedPackageComponent[], + assets: AssetRegistry, +): readonly PackageDocumentSnapshot[] { + const result = new Map(documents.map(document => [document.id, document])); + const points = new Map(); + for (const document of documents) { + for (const point of document.finalizationPoints) + points.set(`${document.id}:${documentFieldKey(point)}`, Object.freeze({ document: document.id, path: point })); + } + const claims = new Set(); + for (const [index, raw] of input.entries()) { + const field = fields(raw, new Set(['document', 'path', 'value', 'componentOrigins']), `Primary Package documentFields[${index}]`); + const document = stableId(field.document?.value, 'Primary Package Document id'); + const path = snapshotFieldPath(field.path?.value, 'Primary Package Document field path'); + const key = `${document}:${documentFieldKey(path)}`; + if (!points.has(key)) + throw new TypeError(`Primary Package targets undeclared finalization point ${key}.`); + if (claims.has(key)) + throw new TypeError(`Primary Package finalization point ${key} is duplicated.`); + const current = result.get(document)!; + const value = snapshotPackageJson(field.value?.value, `Primary Package field ${key}`, assets); + const origins = finalizationOrigins(field.componentOrigins?.value, components, `Primary Package field ${key}`); + /** 一个 Document 有任意 contribution-driven field 时保留其全部可信来源,供 codec Asset 继承。 */ + const mergedOrigins = origins === undefined + ? current.componentOrigins + : Object.freeze([...new Set([...(current.componentOrigins ?? []), ...origins])] + .sort((left, right) => compareCodePoints(left.owner, right.owner) || compareCodePoints(left.subject, right.subject))); + result.set(document, Object.freeze({ + ...current, + value: addDocumentField(current.value, path, value), + ...(mergedOrigins === undefined ? {} : { componentOrigins: mergedOrigins }), + })); + claims.add(key); + } + return Object.freeze([...result.values()].sort((left, right) => compareCodePoints(left.id, right.id))); +} + +/** + * 建立自动继承全部 Package 内容的 primary Unit snapshot。 + * + * @param platform 当前 Platform ID。 + * @param deliveryType Platform definition 的交付类型。 + * @param merged 集中合并后的 Package snapshot。 + * @param input finalizePackage 返回值。 + * @param assets 当前 Session Asset Registry。 + * @returns 包含继承、finalize 与 Document bytes 的完整 primary Unit。 + */ +export async function finalizePrimaryPackage( + platform: string, + deliveryType: PlatformDeliveryType, + merged: MergedPackageSnapshot, + input: PrimaryPackageInput, + assets: AssetRegistry, +): Promise { + /** finalize 输出仍必须越过精确 data-object boundary。 */ + const descriptor = fields(input, new Set(['id', 'type', 'documentFields', 'assets']), 'Primary Package'); + /** Unit ID 是最终 transaction 根身份。 */ + const id = stableId(descriptor.id?.value, 'Primary Package id'); + if (descriptor.type?.value !== deliveryType) + throw new TypeError(`Primary Package type must equal Platform delivery type "${deliveryType}".`); + /** Platform owner 绑定 finalize 新增 Asset 的签发者。 */ + const owner = `platform:${stableId(platform, 'Platform id')}`; + /** Platform 只能在当前 merged Package 已预留的 fields 上作一次 add-only finalization。 */ + const fieldsInput = dataArrayItems(descriptor.documentFields?.value ?? [], 'Primary Package documentFields') as readonly PlatformFinalizationFieldContribution[]; + const documents = finalizeDocuments(merged.documents, fieldsInput, merged.components, assets); + /** primary 重新建立全量路径闭包。 */ + const paths = new Map(); + /** merged Assets 自动继承,Platform 没有可遗漏的选择入口。 */ + const result: PackageAssetSnapshot[] = []; + for (const inherited of merged.assets) { + reservePath(paths, inherited.path, 'Inherited Asset'); + result.push(inherited); + } + /** finalize 可选新增 Asset 也必须使用稠密数组。 */ + const additions = dataArrayItems(descriptor.assets?.value ?? [], 'Primary Package assets'); + for (const addition of additions as readonly PackageAssetInput[]) { + /** Platform addition 先验证 ref grant 与路径碰撞。 */ + const snapshot = assetSnapshot(owner, addition, assets, paths); + if (snapshot.owner !== owner) + throw new TypeError('Primary Package additions must be issued by the current Platform.'); + result.push(snapshot); + } + /** Core codec 产生的 bytes 使用 Platform owner 和结构化 Document provenance。 */ + for (const document of documents) { + if (document.emission === 'omit-if-empty' && documentIsEmpty(document)) + continue; + /** emitted Document 与所有继承 Asset 共用路径闭包。 */ + const path = reservePath(paths, document.path, `Document "${document.id}"`); + /** Core codec bytes 使用结构化 document provenance 签发。 */ + const asset = await assets.issueFinalizationBytes(platform, owner, merged.components, { + bytes: encodePackageDocument(document), + origin: { + operation: 'package-document', + subjects: [`document:${document.id}`], + ...(document.componentOrigins === undefined ? {} : { componentOrigins: document.componentOrigins }), + }, + }); + result.push(Object.freeze({ path, owner, asset })); + } + return Object.freeze({ + platform, + id, + type: deliveryType, + role: 'primary', + assets: Object.freeze(result.sort((left, right) => compareCodePoints(left.path, right.path))), + compatibility: merged.compatibility, + metadata: merged.metadata, + }); +} diff --git a/packages/core/src/package/report-builder.ts b/packages/core/src/package/report-builder.ts new file mode 100644 index 0000000..9d6d83d --- /dev/null +++ b/packages/core/src/package/report-builder.ts @@ -0,0 +1,152 @@ +import type { + BuildReport, + CompatibilityEntry, + ComponentReport, + Diagnostic, + ExtensionReport, + MetadataDispositionEntry, + PackageAssetReport, + PackageUnitReport, + PlatformReport, + RuntimeReport, +} from '../contracts/reports.js'; +import type { PackageUnitSnapshot } from '../contracts/packages.js'; +import { AssetRegistry } from '../services/assets.js'; +import { compareCodePoints } from '../security/path-policy.js'; +import { snapshotJson } from '../security/json-snapshot.js'; + +/** Schema v3 BuildReport 的完整内部输入。 */ +export interface BuildReportInput { + readonly frameworkVersion: string; + readonly compilerVersion: string; + readonly success: boolean; + readonly command: BuildReport['command']; + readonly mode: BuildReport['mode']; + readonly committed: boolean; + readonly components: readonly ComponentReport[]; + readonly runtimes: readonly RuntimeReport[]; + readonly extensions: readonly ExtensionReport[]; + readonly platforms: readonly PlatformReport[]; + readonly packages: readonly PackageUnitSnapshot[]; + readonly validatedPackages?: readonly string[]; + readonly compatibility: readonly CompatibilityEntry[]; + readonly metadata: readonly MetadataDispositionEntry[]; + readonly diagnostics: readonly Diagnostic[]; + readonly assets: AssetRegistry; +} + +/** @returns Package Unit 的无歧义 report key。 */ +function packageKey(unit: Pick): string { + return `${unit.platform}/${unit.id}`; +} + +/** + * 把一个受权 Asset snapshot 投影为包含 structured origin 的报告项。 + * + * @param unit 当前 Package Unit。 + * @param asset 当前路径映射。 + * @param registry BuildSession Asset Registry。 + * @returns 不含字节和物理来源的 Asset report。 + */ +function assetReport( + unit: PackageUnitSnapshot, + asset: PackageUnitSnapshot['assets'][number], + registry: AssetRegistry, +): PackageAssetReport { + /** Package 最终映射必须已获得对应 Asset grant。 */ + const record = registry.describe(`platform:${unit.platform}`, asset.asset); + /** origin 也跨越报告数据边界,不能复用 Registry 内部嵌套容器。 */ + return snapshotJson({ + path: asset.path, + owner: record.owner, + mode: record.mode, + size: record.size, + sha256: record.sha256, + origin: record.origin, + }, `Package ${packageKey(unit)} Asset report`) as unknown as PackageAssetReport; +} + +/** + * 复制一组 report JSON record,使排序不会读取调用方 getter 或可变容器。 + * + * @param value Kernel 阶段收集的 report records。 + * @param label 稳定诊断标签。 + * @returns 深度冻结且与输入断开的 records。 + */ +function reportRecords(value: readonly T[], label: string): readonly T[] { + /** report 集合先整体深拷贝,再执行业务键排序。 */ + const snapshot = snapshotJson(value, label); + if (!Array.isArray(snapshot)) + throw new TypeError(`${label} must be an array.`); + return snapshot as unknown as readonly T[]; +} + +/** + * 创建深度冻结、稳定排序的 Schema v3 BuildReport。 + * + * @param input Kernel 完整生命周期已验证的报告输入。 + * @returns 不含 timestamp、绝对路径、bytes 或环境值的报告。 + */ +export function createBuildReport(input: BuildReportInput): BuildReport { + /** validated key 集合来自 candidate 阶段,不从缺失 unit 推测状态。 */ + const validated = new Set(input.validatedPackages ?? []); + /** Package assets 保留 provenance 并按 path/owner 排序。 */ + const packages: PackageUnitReport[] = input.packages.map(unit => Object.freeze({ + platform: unit.platform, + id: unit.id, + type: unit.type, + role: unit.role, + validated: validated.has(packageKey(unit)), + assets: Object.freeze(unit.assets + .map(asset => assetReport(unit, asset, input.assets)) + .sort((left, right) => compareCodePoints(left.path, right.path) || compareCodePoints(left.owner, right.owner))), + })).sort((left, right) => compareCodePoints(left.platform, right.platform) || compareCodePoints(left.id, right.id)); + /** 每个无业务顺序的报告集合使用明确稳定键排序。 */ + const components = [...reportRecords(input.components, 'BuildReport components')] + .sort((left, right) => compareCodePoints(left.kind, right.kind) || compareCodePoints(left.id, right.id)); + /** Runtime report 只按稳定 entry ID 排序。 */ + const runtimes = [...reportRecords(input.runtimes, 'BuildReport runtimes')].sort((left, right) => compareCodePoints(left.id, right.id)); + /** Extension report 只按稳定 Extension ID 排序。 */ + const extensions = [...reportRecords(input.extensions, 'BuildReport extensions')].sort((left, right) => compareCodePoints(left.id, right.id)); + /** Platform report 只按稳定 Platform ID 排序。 */ + const platforms = [...reportRecords(input.platforms, 'BuildReport platforms')].sort((left, right) => compareCodePoints(left.id, right.id)); + /** Compatibility report 使用完整 tuple key 排序。 */ + const compatibility = [...reportRecords(input.compatibility, 'BuildReport compatibility')] + .sort((left, right) => compareCodePoints(left.platform, right.platform) + || compareCodePoints(left.subject, right.subject) || compareCodePoints(left.capability, right.capability)); + /** Metadata report 使用 Platform/field 排序。 */ + const metadata = [...reportRecords(input.metadata, 'BuildReport metadata')] + .sort((left, right) => compareCodePoints(left.platform, right.platform) + || compareCodePoints(left.field, right.field)); + /** DiagnosticRegistry 已排序,报告层仍建立独立深冻副本。 */ + const diagnostics = reportRecords(input.diagnostics, 'BuildReport diagnostics'); + /** 最终整体 snapshot 同时校验 scalar 和 Package report,形成单一深冻边界。 */ + return snapshotJson({ + schemaVersion: 3, + framework: Object.freeze({ name: 'acplugin', version: input.frameworkVersion }), + compiler: Object.freeze({ name: 'rolldown', version: input.compilerVersion }), + success: input.success, + command: input.command, + mode: input.mode, + committed: input.committed, + components: Object.freeze(components), + runtimes: Object.freeze(runtimes), + extensions: Object.freeze(extensions), + platforms: Object.freeze(platforms), + packages: Object.freeze(packages), + compatibility: Object.freeze(compatibility), + metadata: Object.freeze(metadata), + diagnostics, + }, 'BuildReport') as unknown as BuildReport; +} + +/** + * 把 BuildReport 编码为固定键序和单尾随换行 JSON。 + * + * @param report 已建立边界的 Schema v3 report。 + * @returns 字节稳定 JSON 文本。 + */ +export function serializeBuildReport(report: BuildReport): string { + /** snapshotJson 同时拒绝 report 中意外出现的函数、bytes 或行为对象。 */ + return `${JSON.stringify(snapshotJson(report, 'BuildReport'), null, 2)}\n`; +} diff --git a/packages/core/src/project/project.ts b/packages/core/src/project/project.ts new file mode 100644 index 0000000..aee578c --- /dev/null +++ b/packages/core/src/project/project.ts @@ -0,0 +1,222 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { + BuildReport, + Diagnostic, +} from '../contracts/reports.js'; +import type { + ConfigEnvironment, + UserConfig, + UserConfigExport, +} from '../contracts/config.js'; +import type { + CreateProjectOptions, + DevSession, + Project, + ProjectDevOptions, + ProjectRunOptions, + RunProjectOptions, +} from '../contracts/project.js'; +import { + normalizeProjectRunOptions, + runKernelBuildSession, +} from '../lifecycle/build-session.js'; +import { + createKernelBuildEnvironment, + disposeKernelBuildEnvironment, +} from '../lifecycle/build-environment.js'; +import { createDevSession } from '../lifecycle/dev-session.js'; +import { resolveKernelConfig } from '../config/resolver.js'; +import { isInsidePath, safeRelativePath } from '../security/path-policy.js'; + +/** 配置定位、模块执行或 schema/brand 失败的唯一公开异常。 */ +export class ProjectConfigError extends Error { + /** 可供 CLI/API 消费的稳定配置诊断。 */ + readonly diagnostics: readonly Diagnostic[]; + + /** @param message 安全摘要。 @param diagnostics 稳定配置诊断。 @param cause 内部原始原因。 */ + constructor(message: string, diagnostics: readonly Diagnostic[], cause?: unknown) { + super(message, cause === undefined ? undefined : { cause }); + this.name = 'ProjectConfigError'; + this.diagnostics = Object.freeze([...diagnostics]); + } +} + +/** Project 内部固定且不允许 run() 改写的工程身份。 */ +interface ProjectIdentity { + readonly projectRoot: string; + readonly configRelative: string; + readonly configFile: string; +} + +/** config error 的稳定诊断构造器。 */ +function configDiagnostic(code: string, message: string, location?: string): Diagnostic { + return Object.freeze({ + code, + severity: 'error', + message, + phase: 'config', + ...(location === undefined ? {} : { location: Object.freeze({ path: location }) }), + }); +} + +/** 同步规范化 Project identity,不访问配置内容。 */ +function projectIdentity(options: CreateProjectOptions = {}): ProjectIdentity { + if (typeof options !== 'object' || options === null || Array.isArray(options) + || Object.keys(options).some(field => field !== 'cwd' && field !== 'configFile')) { + throw new ProjectConfigError('Project options are invalid.', [ + configDiagnostic('PROJECT_OPTIONS_INVALID', 'Project options may contain only cwd and configFile.'), + ]); + } + if (options.cwd !== undefined && typeof options.cwd !== 'string') { + throw new ProjectConfigError('Project cwd is invalid.', [ + configDiagnostic('PROJECT_CWD_INVALID', 'Project cwd must be a path string.'), + ]); + } + /** cwd 是 Project 唯一根身份;配置位置不会改变它。 */ + const projectRoot = path.resolve(options.cwd ?? process.cwd()); + /** 显式 configFile 使用与输出一致的严格 project-relative POSIX 语法。 */ + let configRelative = 'acplugin.config.ts'; + if (options.configFile !== undefined) { + try { + if (typeof options.configFile !== 'string') + throw new TypeError('invalid'); + configRelative = safeRelativePath(options.configFile); + } catch { + throw new ProjectConfigError('Project configFile is invalid.', [ + configDiagnostic('CONFIG_FILE_PATH_INVALID', 'configFile must be a project-relative POSIX path.'), + ]); + } + } + if (!configRelative.endsWith('.ts')) { + throw new ProjectConfigError('Project configFile is invalid.', [ + configDiagnostic('CONFIG_FILE_EXTENSION_INVALID', 'configFile must reference a TypeScript file.', configRelative), + ]); + } + /** safeRelativePath 与逐 segment join 共同避免宿主路径 normalize 接受歧义输入。 */ + const configFile = path.join(projectRoot, ...configRelative.split('/')); + if (!isInsidePath(projectRoot, configFile)) + throw new ProjectConfigError('Project configFile escapes the project root.', [ + configDiagnostic('CONFIG_FILE_OUTSIDE_PROJECT', 'configFile must stay inside the project root.'), + ]); + return Object.freeze({ projectRoot, configRelative, configFile }); +} + +/** 验证工程根和配置入口的物理普通文件边界。 */ +async function validateConfigEntry(identity: ProjectIdentity): Promise { + /** root 必须是非 symlink 的真实目录。 */ + const root = await fs.lstat(identity.projectRoot).catch(() => undefined); + if (root === undefined || !root.isDirectory() || root.isSymbolicLink()) { + throw new ProjectConfigError('Project cwd is not a usable directory.', [ + configDiagnostic('PROJECT_CWD_INVALID', 'Project cwd must be a regular directory.'), + ]); + } + /** entry 必须是 Project identity 内的非 symlink 普通文件。 */ + const entry = await fs.lstat(identity.configFile).catch(() => undefined); + if (entry === undefined || !entry.isFile() || entry.isSymbolicLink()) { + throw new ProjectConfigError(`Cannot load ${identity.configRelative}.`, [ + configDiagnostic('CONFIG_LOAD_FAILED', 'Configuration must be a regular non-symlink file.', identity.configRelative), + ]); + } +} + +/** 在当前 BuildSession 的唯一 Module Host 内 fresh evaluate 配置。 */ +async function loadConfig( + identity: ProjectIdentity, + environment: Awaited>, + command: ConfigEnvironment['command'], + mode: ConfigEnvironment['mode'], +): Promise { + await validateConfigEntry(identity); + /** config owner 只获得 Project root 下当前显式入口的 Source capability。 */ + const root = await environment.sources.issueRoot('framework:config', identity.projectRoot); + /** entry ref 与当前 BuildSession owner/session identity 绑定。 */ + const entry = await environment.sources.service('framework:config').file(root, identity.configRelative); + /** exported 在 Module Host fresh evaluation 后才进入 config data boundary。 */ + let exported: UserConfigExport; + try { + exported = await environment.modules.service('framework:config').loadDefault({ + id: 'project-config', + entry, + }); + } catch (error) { + throw new ProjectConfigError(`Cannot evaluate ${identity.configRelative}.`, [ + configDiagnostic('CONFIG_EVALUATION_FAILED', 'Configuration module evaluation failed.', identity.configRelative), + ], error); + } + /** 函数式配置只观察冻结 command/mode,不接触路径或环境值。 */ + let value: UserConfig; + try { + value = (typeof exported === 'function' + ? await exported(Object.freeze({ command, mode })) + : exported) as UserConfig; + } catch (error) { + throw new ProjectConfigError(`Configuration function in ${identity.configRelative} failed.`, [ + configDiagnostic('CONFIG_FUNCTION_FAILED', 'Configuration function failed.', identity.configRelative), + ], error); + } + /** Core resolver 负责完整 plain-data/brand/path/schema 边界。 */ + const resolved = resolveKernelConfig(value, { + projectRoot: identity.projectRoot, + configFile: identity.configFile, + command, + mode, + }); + if (resolved.config === undefined) { + throw new ProjectConfigError('Project configuration is invalid.', resolved.diagnostics); + } + return resolved.config; +} + +/** 使用固定 Project identity 创建只委托唯一 BuildSession 的程序化对象。 */ +export function createKernelProject(options: CreateProjectOptions, frameworkVersion: string): Project { + /** identity 在 Project 创建时固定,后续 run/dev 不可切换。 */ + const identity = projectIdentity(options); + return Object.freeze({ + /** 每次 run 创建全新 capability/Host/Integration Session。 */ + async run(runOptions: ProjectRunOptions = {}): Promise { + /** normalized 固定 command/mode/selection/commit 语义。 */ + const normalized = normalizeProjectRunOptions(runOptions); + /** 每轮 run 使用独立 environment,支持同一 Project 并发调用。 */ + const environment = await createKernelBuildEnvironment(identity.projectRoot); + try { + /** config 必须在当前 environment 的唯一 Module Host 中执行。 */ + const config = await loadConfig(identity, environment, normalized.command, normalized.mode); + /** result 来自唯一 Kernel BuildSession,不经过 facade 二次转换。 */ + const result = await runKernelBuildSession({ + config, + frameworkVersion, + ...(normalized.selection === undefined ? {} : { selection: normalized.selection }), + commit: normalized.commit, + environment, + }); + return result.report; + } finally { + await disposeKernelBuildEnvironment(environment); + } + }, + /** DevSession watcher ownership与 one-shot BuildSession 共用同一 Core coordinator。 */ + async dev(devOptions: ProjectDevOptions = {}): Promise { + return createDevSession({ + projectRoot: identity.projectRoot, + configFile: identity.configFile, + frameworkVersion, + options: devOptions, + /** 每轮 DevSession 都在自己的受管环境执行配置。 */ + loadConfig: async roundEnvironment => loadConfig(identity, roundEnvironment, 'dev', devOptions.mode ?? 'development'), + /** 首轮配置失败保留可恢复的稳定诊断。 */ + initialConfigError: error => error instanceof ProjectConfigError ? error.diagnostics : [], + }); + }, + }); +} + +/** runProject 是 createProject().run() 的无逻辑 convenience。 */ +export async function runKernelProject(options: RunProjectOptions, frameworkVersion: string): Promise { + /** Project identity options 与单轮 run options 只在此拆分一次。 */ + const { cwd, configFile, ...runOptions } = options; + return createKernelProject({ + ...(cwd === undefined ? {} : { cwd }), + ...(configFile === undefined ? {} : { configFile }), + }, frameworkVersion).run(runOptions); +} diff --git a/packages/core/src/resources/canonical/agents.ts b/packages/core/src/resources/canonical/agents.ts new file mode 100644 index 0000000..af3d963 --- /dev/null +++ b/packages/core/src/resources/canonical/agents.ts @@ -0,0 +1,78 @@ +import type { AgentCapability, AgentComponent, AgentModel } from '../../contracts/components.js'; +import type { SourceDirectoryRef } from '../../contracts/services.js'; +import { DiagnosticRegistry } from '../../services/diagnostics.js'; +import { compareCodePoints } from '../../security/path-policy.js'; +import { SourceRegistry } from '../../services/sources.js'; +import { componentId, error, fields, parseMarkdown, platforms, requires, rootEntries, stringField, strings } from './shared.js'; + +/** Core 支持的平台中立 Agent model。 */ +const AGENT_MODELS = new Set(['inherit', 'fast', 'capable']); + +/** Core 支持的平台中立 Agent capability。 */ +const AGENT_CAPABILITIES = new Set([ + 'filesystem:read', 'filesystem:write', 'search', 'shell', 'network', 'delegate', +]); + +/** + * 扫描 Agent root。 + * + * @param root 可选 agents root。 + * @param sources canonical Source Service。 + * @param configured 配置 Platform IDs。 + * @param diagnostics 当前诊断集合。 + * @returns 有效 Agents。 + */ +export async function discoverAgents( + root: SourceDirectoryRef | undefined, + sources: ReturnType, + configured: ReadonlySet, + diagnostics: DiagnosticRegistry, +): Promise { + /** Agent 结果不携带任何平台物理输出信息。 */ + const result: AgentComponent[] = []; + for (const entry of await rootEntries(root, sources)) { + if (entry.type !== 'file' || !entry.name.endsWith('.md')) { + error(diagnostics, 'AGENT_ENTRY_INVALID', 'Agents must be one-level .md files.', entry.path); + continue; + } + /** Agent ID 来自精确 .md 文件名。 */ + const id = entry.name.slice(0, -3); + if (!componentId(id, entry.path, diagnostics)) + continue; + /** Agent 主文件使用相同严格 Frontmatter parser。 */ + const markdown = await parseMarkdown(sources, entry.file, diagnostics); + if (markdown === undefined) + continue; + fields(markdown.data, ['description', 'model', 'capabilities', 'requires', 'platforms'], entry.path, diagnostics); + /** description 缺失时不创建 Agent。 */ + const description = stringField(markdown.data, 'description', entry.path, diagnostics, true); + if (description === undefined) + continue; + /** 未配置模型时保持跨平台的 inherit 语义。 */ + const rawModel = markdown.data.model ?? 'inherit'; + /** 非法模型回退用于继续收集诊断,但错误会阻止构建。 */ + const model: AgentModel = typeof rawModel === 'string' && AGENT_MODELS.has(rawModel as AgentModel) ? rawModel as AgentModel : 'inherit'; + if (model !== rawModel) + error(diagnostics, 'AGENT_MODEL_INVALID', 'model must be inherit, fast, or capable.', entry.path, ['model']); + /** capability 只保留 Core 定义的平台中立集合。 */ + const capabilities = strings(markdown.data.capabilities, ['capabilities'], entry.path, diagnostics) + .filter((capability): capability is AgentCapability => { + if (AGENT_CAPABILITIES.has(capability as AgentCapability)) + return true; + error(diagnostics, 'AGENT_CAPABILITY_INVALID', `Unknown capability "${capability}".`, entry.path, ['capabilities']); + return false; + }); + result.push(Object.freeze({ + kind: 'agent', + id, + description, + model, + capabilities: Object.freeze(capabilities), + body: markdown.body, + location: Object.freeze({ path: entry.path, bodyLine: markdown.bodyLine }), + requires: requires(markdown.data.requires, entry.path, diagnostics), + platforms: platforms(markdown.data.platforms, configured, entry.path, diagnostics), + })); + } + return Object.freeze(result.sort((left, right) => compareCodePoints(left.id, right.id))); +} diff --git a/packages/core/src/resources/canonical/commands.ts b/packages/core/src/resources/canonical/commands.ts new file mode 100644 index 0000000..ad5d63e --- /dev/null +++ b/packages/core/src/resources/canonical/commands.ts @@ -0,0 +1,61 @@ +import type { CommandComponent } from '../../contracts/components.js'; +import type { SourceDirectoryRef } from '../../contracts/services.js'; +import { DiagnosticRegistry } from '../../services/diagnostics.js'; +import { compareCodePoints } from '../../security/path-policy.js'; +import { SourceRegistry } from '../../services/sources.js'; +import { componentId, error, fields, parseMarkdown, platforms, requires, rootEntries, stringField } from './shared.js'; + +/** + * 扫描 Command root。 + * + * @param root 可选 commands root。 + * @param sources canonical Source Service。 + * @param configured 配置 Platform IDs。 + * @param diagnostics 当前诊断集合。 + * @returns 有效 Commands。 + */ +export async function discoverCommands( + root: SourceDirectoryRef | undefined, + sources: ReturnType, + configured: ReadonlySet, + diagnostics: DiagnosticRegistry, +): Promise { + /** 扫描结果在完成后按 ID 排序并冻结。 */ + const result: CommandComponent[] = []; + for (const entry of await rootEntries(root, sources)) { + if (entry.type !== 'file' || !entry.name.endsWith('.md')) { + error(diagnostics, 'COMMAND_ENTRY_INVALID', 'Commands must be one-level .md files.', entry.path); + continue; + } + /** Command ID 来自精确 .md 文件名。 */ + const id = entry.name.slice(0, -3); + if (!componentId(id, entry.path, diagnostics)) + continue; + /** Markdown parsing 只使用当前 owner 的 SourceRef。 */ + const markdown = await parseMarkdown(sources, entry.file, diagnostics); + if (markdown === undefined) + continue; + fields(markdown.data, ['description', 'argumentHint', 'requires', 'platforms'], entry.path, diagnostics); + /** description 是所有 canonical Component 的必填字段。 */ + const description = stringField(markdown.data, 'description', entry.path, diagnostics, true); + if (description === undefined) + continue; + for (const placeholder of markdown.body.match(/\{\{[^{}]*\}\}/gu) ?? []) { + if (placeholder !== '{{arguments}}') + error(diagnostics, 'COMMAND_PLACEHOLDER_INVALID', `Unsupported Command placeholder "${placeholder}".`, entry.path); + } + /** argumentHint 保持可选且不解释平台语义。 */ + const argumentHint = stringField(markdown.data, 'argumentHint', entry.path, diagnostics); + result.push(Object.freeze({ + kind: 'command', + id, + description, + ...(argumentHint === undefined ? {} : { argumentHint }), + body: markdown.body, + location: Object.freeze({ path: entry.path, bodyLine: markdown.bodyLine }), + requires: requires(markdown.data.requires, entry.path, diagnostics), + platforms: platforms(markdown.data.platforms, configured, entry.path, diagnostics), + })); + } + return Object.freeze(result.sort((left, right) => compareCodePoints(left.id, right.id))); +} diff --git a/packages/core/src/resources/canonical/provider.ts b/packages/core/src/resources/canonical/provider.ts new file mode 100644 index 0000000..375f39d --- /dev/null +++ b/packages/core/src/resources/canonical/provider.ts @@ -0,0 +1,130 @@ +import type { + AgentComponent, + CanonicalProject, + CommandComponent, + SkillComponent, +} from '../../contracts/components.js'; +import type { PluginMetadata } from '../../contracts/config.js'; +import { AssetRegistry } from '../../services/assets.js'; +import { DiagnosticRegistry } from '../../services/diagnostics.js'; +import { compareCodePoints } from '../../security/path-policy.js'; +import { SourceRegistry } from '../../services/sources.js'; +import type { CanonicalResourceRoot, ResourceClaims } from '../registry.js'; +import { discoverAgents } from './agents.js'; +import { discoverCommands } from './commands.js'; +import { discoverSkills } from './skills.js'; + +/** + * 校验跨 Command/Skill/Agent 的依赖图。 + * + * @param components 完整 canonical Component 集。 + * @param diagnostics 当前诊断集合。 + */ +function validateGraph( + components: readonly (CommandComponent | SkillComponent | AgentComponent)[], + diagnostics: DiagnosticRegistry, +): void { + /** kind+id 是允许不同 Component 类型同名的图键。 */ + const key = (kind: string, id: string): string => `${kind}:${id}`; + /** 完整 Component 索引用于检查引用存在性。 */ + const byKey = new Map(components.map(component => [key(component.kind, component.id), component])); + /** 只记录通过存在性和自引用检查的有向边。 */ + const edges = new Map(); + for (const component of components) { + /** 当前 Component 的唯一图节点键。 */ + const from = key(component.kind, component.id); + /** Command/Skill/Agent 统一投影为可引用 Skill/Agent 目标。 */ + const targets = [ + ...component.requires.skills.map(id => key('skill', id)), + ...component.requires.agents.map(id => key('agent', id)), + ]; + /** 合法边按目标键稳定排序后进入 DFS。 */ + const valid: string[] = []; + for (const target of targets) { + if (target === from) { + diagnostics.report('validate', { code: 'COMPONENT_DEPENDENCY_SELF', severity: 'error', message: `${from} cannot require itself.`, location: { path: component.location.path } }, { owner: 'framework:canonical', component: { kind: component.kind, id: component.id } }); + } else if (!byKey.has(target)) { + diagnostics.report('validate', { code: 'COMPONENT_DEPENDENCY_MISSING', severity: 'error', message: `${from} requires missing ${target}.`, location: { path: component.location.path } }, { owner: 'framework:canonical', component: { kind: component.kind, id: component.id } }); + } else { + valid.push(target); + } + } + edges.set(from, valid.sort(compareCodePoints)); + } + /** visiting 表示当前 DFS 路径上的灰色节点。 */ + const visiting = new Set(); + /** visited 表示已经完成验证的黑色节点。 */ + const visited = new Set(); + /** stack 保留完整循环路径用于稳定诊断。 */ + const stack: string[] = []; + /** reported 避免同一环路从多个入口重复报告。 */ + const reported = new Set(); + /** 深度优先遍历检测依赖图中的回边。 */ + const visit = (node: string): void => { + if (visited.has(node)) + return; + if (visiting.has(node)) { + /** 回边闭合为包含首尾节点的完整可读路径。 */ + const cycle = [...stack.slice(stack.indexOf(node)), node].join(' -> '); + if (!reported.has(cycle)) { + diagnostics.report('validate', { code: 'COMPONENT_DEPENDENCY_CYCLE', severity: 'error', message: `Dependency cycle: ${cycle}.` }, { owner: 'framework:canonical' }); + reported.add(cycle); + } + return; + } + visiting.add(node); + stack.push(node); + for (const target of edges.get(node) ?? []) + visit(target); + stack.pop(); + visiting.delete(node); + visited.add(node); + }; + for (const node of [...byKey.keys()].sort(compareCodePoints)) + visit(node); +} + +/** Canonical Provider 的 Session registries。 */ +export interface CanonicalProviderOptions { + readonly metadata: Readonly; + readonly platformIds: readonly string[]; + readonly claims: ResourceClaims; + readonly sources: SourceRegistry; + readonly assets: AssetRegistry; + readonly diagnostics: DiagnosticRegistry; +} + +/** + * 发现并验证 Canonical Component graph。 + * + * Public 和 Runtime 由各自 Provider 合并,因此这里先返回空 publicFiles。 + * + * @param options 当前 BuildSession registries 与 claims。 + * @returns 不含物理路径的不可变 canonical project。 + */ +export async function discoverCanonicalProject(options: CanonicalProviderOptions): Promise { + /** canonical Source capability 固定绑定 Framework owner。 */ + const sourceService = options.sources.service('framework:canonical'); + /** Skill auxiliary Asset 同样保留 canonical issuer。 */ + const assetService = options.assets.service('framework:canonical'); + /** configured Set 只用于拒绝未安装 Platform namespace。 */ + const configured = new Set(options.platformIds); + /** 三类互相独立的来源并行扫描,最终诊断由 Registry 排序。 */ + const [discoveredCommands, discoveredSkills, discoveredAgents] = await Promise.all([ + discoverCommands(options.claims.canonical.commands, sourceService, configured, options.diagnostics), + discoverSkills(options.claims.canonical.skills, sourceService, assetService, configured, options.diagnostics), + discoverAgents(options.claims.canonical.agents, sourceService, configured, options.diagnostics), + ]); + validateGraph([...discoveredCommands, ...discoveredSkills, ...discoveredAgents], options.diagnostics); + return Object.freeze({ + metadata: options.metadata, + commands: discoveredCommands, + skills: discoveredSkills, + agents: discoveredAgents, + publicFiles: Object.freeze([]), + }); +} + +/** Framework canonical root names的 compile-time exhaustiveness guard。 */ +const _canonicalRoots: readonly CanonicalResourceRoot[] = ['commands', 'skills', 'agents']; +void _canonicalRoots; diff --git a/packages/core/src/resources/canonical/shared.ts b/packages/core/src/resources/canonical/shared.ts new file mode 100644 index 0000000..3f32c3a --- /dev/null +++ b/packages/core/src/resources/canonical/shared.ts @@ -0,0 +1,326 @@ +import { parseDocument } from 'yaml'; +import type { ComponentRequires } from '../../contracts/components.js'; +import type { JsonObject, JsonValue } from '../../contracts/common.js'; +import type { SourceDirectoryRef, SourceEntry, SourceFileRef } from '../../contracts/services.js'; +import { DiagnosticRegistry } from '../../services/diagnostics.js'; +import { SourceRegistry } from '../../services/sources.js'; + +/** Component ID 的规范格式。 */ +const COMPONENT_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + +/** Markdown 主文件的统一中间形态。 */ +export interface ParsedMarkdown { + readonly data: Readonly>; + readonly body: string; + readonly bodyLine: number; +} + +/** + * 提交绑定 canonical owner 的诊断。 + * + * @param diagnostics 当前 Session Registry。 + * @param code 稳定诊断码。 + * @param message 稳定信息。 + * @param location 工程相对路径。 + * @param fieldPath 可选字段路径。 + */ +export function error( + diagnostics: DiagnosticRegistry, + code: string, + message: string, + location?: string, + fieldPath?: readonly (string | number)[], +): void { + diagnostics.report('discover', { + code, + severity: 'error', + message, + ...(location === undefined ? {} : { location: { path: location } }), + ...(fieldPath === undefined ? {} : { fieldPath }), + }, { owner: 'framework:canonical' }); +} + +/** + * 解析严格 UTF-8 + YAML Frontmatter Markdown。 + * + * @param sources canonical owner Source Service。 + * @param file Markdown SourceRef。 + * @param diagnostics 当前诊断集合。 + * @returns 合法 Frontmatter、正文和正文行。 + */ +export async function parseMarkdown( + sources: ReturnType, + file: SourceFileRef, + diagnostics: DiagnosticRegistry, +): Promise { + /** 文本读取失败统一转换为稳定 UTF-8 诊断。 */ + let source: string; + try { + source = await sources.readText(file); + } catch { + error(diagnostics, 'MARKDOWN_UTF8_INVALID', 'Markdown must be stable UTF-8 text.', file.path); + return undefined; + } + /** 保留行边界用于定位正文。 */ + const lines = source.split(/\r?\n/u); + if (lines[0] !== '---') { + error(diagnostics, 'FRONTMATTER_REQUIRED', 'Markdown requires a YAML Frontmatter block.', file.path); + return undefined; + } + /** closing 是 Frontmatter 结束分隔符的零基行索引。 */ + const closing = lines.findIndex((line, index) => index > 0 && line === '---'); + if (closing < 0) { + error(diagnostics, 'FRONTMATTER_UNTERMINATED', 'YAML Frontmatter is not terminated.', file.path); + return undefined; + } + /** YAML parser 必须拒绝重复键和非法语法。 */ + const document = parseDocument(lines.slice(1, closing).join('\n'), { prettyErrors: false, uniqueKeys: true }); + if (document.errors.length > 0) { + error(diagnostics, 'FRONTMATTER_INVALID', 'YAML Frontmatter is invalid.', file.path); + return undefined; + } + /** YAML AST 只在无 parser errors 后投影为普通值。 */ + const value = document.toJS() as unknown; + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + error(diagnostics, 'FRONTMATTER_OBJECT_REQUIRED', 'Frontmatter must be a mapping.', file.path); + return undefined; + } + /** 正文统一换行为 LF 并去除首尾空白。 */ + const body = lines.slice(closing + 1).join('\n').trim(); + if (body.length === 0) { + error(diagnostics, 'MARKDOWN_BODY_REQUIRED', 'Markdown body must not be empty.', file.path); + return undefined; + } + return Object.freeze({ data: value as Record, body, bodyLine: closing + 2 }); +} + +/** + * 拒绝 Frontmatter unknown/legacy fields。 + * + * @param data Frontmatter mapping。 + * @param allowed 当前 Component 白名单。 + * @param location Markdown 路径。 + * @param diagnostics 当前诊断集合。 + */ +export function fields(data: Readonly>, allowed: readonly string[], location: string, diagnostics: DiagnosticRegistry): void { + /** Set 使每个 Frontmatter 字段只需常量时间查找。 */ + const accepted = new Set(allowed); + for (const field of Object.keys(data).sort()) { + if (field === 'extensions') { + error(diagnostics, 'COMPONENT_LEGACY_EXTENSIONS', 'Frontmatter extensions is not supported; use platforms.', location, [field]); + } else if (!accepted.has(field)) { + error(diagnostics, 'FRONTMATTER_FIELD_UNKNOWN', `Unknown Frontmatter field "${field}".`, location, [field]); + } + } +} + +/** + * 读取非空 string 字段。 + * + * @param data Frontmatter mapping。 + * @param field 字段名。 + * @param location 文件路径。 + * @param diagnostics 当前诊断集合。 + * @param required 缺失时是否失败。 + * @returns 规范化 string 或 undefined。 + */ +export function stringField( + data: Readonly>, + field: string, + location: string, + diagnostics: DiagnosticRegistry, + required = false, +): string | undefined { + /** 字段读取不执行额外 coercion。 */ + const value = data[field]; + if (value === undefined && !required) + return undefined; + if (typeof value !== 'string' || value.trim() === '') { + error(diagnostics, 'FRONTMATTER_STRING_REQUIRED', `${field} must be a non-empty string.`, location, [field]); + return undefined; + } + return value.trim(); +} + +/** + * 复制严格 string array。 + * + * @param value 未知数组值。 + * @param fieldPath 字段路径。 + * @param location 文件路径。 + * @param diagnostics 当前诊断集合。 + * @returns 排除非 string 后的稳定数组。 + */ +export function strings( + value: unknown, + fieldPath: readonly string[], + location: string, + diagnostics: DiagnosticRegistry, +): readonly string[] { + if (value === undefined) + return Object.freeze([]); + if (!Array.isArray(value) || value.some(item => typeof item !== 'string' || item.trim() === '')) { + error(diagnostics, 'FRONTMATTER_STRING_ARRAY', `${fieldPath.join('.')} must be an array of non-empty strings.`, location, fieldPath); + return Object.freeze([]); + } + /** 复制数组,避免 YAML 容器身份进入 Project Graph。 */ + const result = [...value] as string[]; + if (new Set(result).size !== result.length) + error(diagnostics, 'FRONTMATTER_ARRAY_DUPLICATE', `${fieldPath.join('.')} must not contain duplicates.`, location, fieldPath); + return Object.freeze(result); +} + +/** + * 解析 canonical dependency 声明。 + * + * @param value requires Frontmatter 值。 + * @param location 文件路径。 + * @param diagnostics 当前诊断集合。 + * @returns 始终包含 skills/agents 的不可变依赖。 + */ +export function requires(value: unknown, location: string, diagnostics: DiagnosticRegistry): ComponentRequires { + if (value === undefined) + return Object.freeze({ skills: Object.freeze([]), agents: Object.freeze([]) }); + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + error(diagnostics, 'COMPONENT_REQUIRES_INVALID', 'requires must be a mapping.', location, ['requires']); + return Object.freeze({ skills: Object.freeze([]), agents: Object.freeze([]) }); + } + /** YAML 映射的普通字段。 */ + const object = value as Record; + for (const field of Object.keys(object)) { + if (field !== 'skills' && field !== 'agents') + error(diagnostics, 'COMPONENT_REQUIRES_KIND', `requires.${field} is not supported.`, location, ['requires', field]); + } + /** 两种可引用 Component 类型分别解析并保留声明顺序。 */ + const skills = strings(object.skills, ['requires', 'skills'], location, diagnostics); + /** Agent dependencies 与 Skill dependencies 使用相同 ID 规则。 */ + const agents = strings(object.agents, ['requires', 'agents'], location, diagnostics); + for (const [kind, ids] of [['skills', skills], ['agents', agents]] as const) { + for (const [index, id] of ids.entries()) { + if (!COMPONENT_ID.test(id)) + error(diagnostics, 'COMPONENT_REQUIRES_ID_INVALID', `requires.${kind} contains an invalid Component ID.`, location, ['requires', kind, index]); + } + } + return Object.freeze({ skills, agents }); +} + +/** + * 递归复制 YAML value 为严格 JSON。 + * + * @param value 当前值。 + * @param path 字段路径。 + * @param location 文件路径。 + * @param diagnostics 当前诊断集合。 + * @param ancestors 当前递归祖先。 + * @returns JSON snapshot 或 undefined。 + */ +function jsonValue( + value: unknown, + path: readonly string[], + location: string, + diagnostics: DiagnosticRegistry, + ancestors = new Set(), +): JsonValue | undefined { + if (value === null || typeof value === 'string' || typeof value === 'boolean') + return value; + if (typeof value === 'number') { + if (Number.isFinite(value)) + return value; + error(diagnostics, 'COMPONENT_PLATFORM_JSON_INVALID', 'Platform metadata must contain finite JSON values.', location, path); + return undefined; + } + if (typeof value !== 'object') { + error(diagnostics, 'COMPONENT_PLATFORM_JSON_INVALID', 'Platform metadata must contain JSON values.', location, path); + return undefined; + } + if (ancestors.has(value)) { + error(diagnostics, 'COMPONENT_PLATFORM_JSON_CYCLE', 'Platform metadata must not contain cycles.', location, path); + return undefined; + } + ancestors.add(value); + try { + if (Array.isArray(value)) { + /** JSON array 使用新容器逐项规范化。 */ + const result: JsonValue[] = []; + for (const [index, item] of value.entries()) { + /** index 加入字段路径以生成精确诊断。 */ + const normalized = jsonValue(item, [...path, String(index)], location, diagnostics, ancestors); + if (normalized === undefined) + return undefined; + result.push(normalized); + } + return Object.freeze(result); + } + if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) { + error(diagnostics, 'COMPONENT_PLATFORM_JSON_INVALID', 'Platform metadata must use plain mappings.', location, path); + return undefined; + } + /** JSON object 使用冻结的新 data-property 容器。 */ + const result: Record = {}; + for (const field of Object.keys(value).sort()) { + /** 字段按稳定键序递归复制。 */ + const normalized = jsonValue((value as Record)[field], [...path, field], location, diagnostics, ancestors); + if (normalized === undefined) + return undefined; + Object.defineProperty(result, field, { value: normalized, enumerable: true, configurable: false, writable: false }); + } + return Object.freeze(result); + } finally { + ancestors.delete(value); + } +} + +/** + * 解析 Component 的 configured Platform 专属 JSON。 + * + * @param value platforms Frontmatter 值。 + * @param configured 已配置 Platform ID。 + * @param location 文件路径。 + * @param diagnostics 当前诊断集合。 + * @returns 仅保留已配置平台的冻结 JSON object map。 + */ +export function platforms( + value: unknown, + configured: ReadonlySet, + location: string, + diagnostics: DiagnosticRegistry, +): Readonly>> { + if (value === undefined) + return Object.freeze({}); + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + error(diagnostics, 'COMPONENT_PLATFORMS_INVALID', 'platforms must be a mapping.', location, ['platforms']); + return Object.freeze({}); + } + /** 只保留当前配置中实际存在的 Platform namespace。 */ + const result: Record> = {}; + for (const id of Object.keys(value).sort()) { + if (!configured.has(id)) { + error(diagnostics, 'COMPONENT_PLATFORM_NOT_CONFIGURED', `Component declares unconfigured Platform "${id}".`, location, ['platforms', id]); + continue; + } + /** Platform fields 只能是严格 JSON mapping。 */ + const normalized = jsonValue((value as Record)[id], ['platforms', id], location, diagnostics); + if (normalized === undefined || normalized === null || typeof normalized !== 'object' || Array.isArray(normalized)) { + error(diagnostics, 'COMPONENT_PLATFORM_FIELDS_INVALID', `platforms.${id} must be a JSON mapping.`, location, ['platforms', id]); + continue; + } + result[id] = normalized as Readonly; + } + return Object.freeze(result); +} + +/** @returns Resource root 的直接 entries;缺失 root 返回空集合。 */ +export async function rootEntries( + root: SourceDirectoryRef | undefined, + sources: ReturnType, +): Promise { + return root === undefined ? Object.freeze([]) : sources.list(root); +} + +/** @returns entry 的 Component ID 是否有效,并在失败时报告。 */ +export function componentId(id: string, location: string, diagnostics: DiagnosticRegistry): boolean { + if (COMPONENT_ID.test(id)) + return true; + error(diagnostics, 'COMPONENT_ID_INVALID', `Component ID "${id}" must use lowercase kebab-case.`, location); + return false; +} diff --git a/packages/core/src/resources/canonical/skills.ts b/packages/core/src/resources/canonical/skills.ts new file mode 100644 index 0000000..5465eca --- /dev/null +++ b/packages/core/src/resources/canonical/skills.ts @@ -0,0 +1,105 @@ +import type { SkillComponent } from '../../contracts/components.js'; +import type { SourceAssetRef, SourceDirectoryRef, SourceFileRef } from '../../contracts/services.js'; +import { AssetRegistry } from '../../services/assets.js'; +import { DiagnosticRegistry } from '../../services/diagnostics.js'; +import { compareCodePoints, safeRelativePath } from '../../security/path-policy.js'; +import { SourceRegistry } from '../../services/sources.js'; +import { componentId, error, fields, parseMarkdown, platforms, requires, rootEntries, stringField } from './shared.js'; + +/** + * 扫描 Skill root 和辅助资源。 + * + * @param root 可选 skills root。 + * @param sources canonical Source Service。 + * @param assets canonical Asset Service。 + * @param configured 配置 Platform IDs。 + * @param diagnostics 当前诊断集合。 + * @returns 有效 Skills。 + */ +export async function discoverSkills( + root: SourceDirectoryRef | undefined, + sources: ReturnType, + assets: ReturnType, + configured: ReadonlySet, + diagnostics: DiagnosticRegistry, +): Promise { + /** Skill 结果在所有辅助资源完成签发后统一冻结。 */ + const result: SkillComponent[] = []; + for (const entry of await rootEntries(root, sources)) { + if (entry.type !== 'directory') { + error(diagnostics, 'SKILL_ENTRY_INVALID', 'Skills must be one-level directories.', entry.path); + continue; + } + if (!componentId(entry.name, entry.path, diagnostics)) + continue; + /** 每个 Skill 必须拥有精确名称的主 Markdown 文件。 */ + let skillFile: SourceFileRef; + try { + skillFile = await sources.file(entry.directory, 'SKILL.md'); + } catch { + error(diagnostics, 'SKILL_FILE_REQUIRED', 'Skill directory must contain SKILL.md.', entry.path); + continue; + } + /** Skill 主文件沿用 canonical Markdown 解析边界。 */ + const markdown = await parseMarkdown(sources, skillFile, diagnostics); + if (markdown === undefined) + continue; + fields(markdown.data, ['description', 'invocation', 'requires', 'platforms'], skillFile.path, diagnostics); + /** description 缺失时不能产生不完整 Skill。 */ + const description = stringField(markdown.data, 'description', skillFile.path, diagnostics, true); + if (description === undefined) + continue; + /** Skill 默认允许用户显式调用。 */ + let user = true; + /** Skill 默认也允许模型自动选择。 */ + let model = true; + if (markdown.data.invocation !== undefined) { + /** invocation 保留平台中立的两个布尔维度。 */ + const invocation = markdown.data.invocation; + if (typeof invocation !== 'object' || invocation === null || Array.isArray(invocation)) { + error(diagnostics, 'SKILL_INVOCATION_INVALID', 'invocation must be a mapping.', skillFile.path, ['invocation']); + } else { + for (const field of Object.keys(invocation)) { + if (field !== 'user' && field !== 'model') + error(diagnostics, 'SKILL_INVOCATION_FIELD', `Unknown invocation field "${field}".`, skillFile.path, ['invocation', field]); + } + /** invocationUser 只接受显式布尔值。 */ + const invocationUser = (invocation as Record).user; + if (typeof invocationUser === 'boolean') + user = invocationUser; + else if ((invocation as Record).user !== undefined) + error(diagnostics, 'SKILL_INVOCATION_BOOLEAN', 'invocation.user must be boolean.', skillFile.path, ['invocation', 'user']); + /** invocationModel 使用与 user 相同的严格布尔边界。 */ + const invocationModel = (invocation as Record).model; + if (typeof invocationModel === 'boolean') + model = invocationModel; + else if ((invocation as Record).model !== undefined) + error(diagnostics, 'SKILL_INVOCATION_BOOLEAN', 'invocation.model must be boolean.', skillFile.path, ['invocation', 'model']); + } + } + if (!user && !model) + error(diagnostics, 'SKILL_INVOCATION_EMPTY', 'invocation.user and invocation.model cannot both be false.', skillFile.path, ['invocation']); + /** 递归枚举后只把普通辅助文件签发为 SourceAsset。 */ + const auxiliary = [] as { path: string; asset: SourceAssetRef }[]; + for (const child of await sources.list(entry.directory, { recursive: true })) { + if (child.type !== 'file' || child.path === skillFile.path) + continue; + /** 辅助资源路径相对 Skill 根而不是项目根。 */ + const relative = child.path.slice(`${entry.path}/`.length); + auxiliary.push(Object.freeze({ path: safeRelativePath(relative), asset: await assets.fromSource(child.file) })); + } + auxiliary.sort((left, right) => compareCodePoints(left.path, right.path)); + result.push(Object.freeze({ + kind: 'skill', + id: entry.name, + description, + invocation: Object.freeze({ user, model }), + body: markdown.body, + location: Object.freeze({ path: skillFile.path, bodyLine: markdown.bodyLine }), + requires: requires(markdown.data.requires, skillFile.path, diagnostics), + platforms: platforms(markdown.data.platforms, configured, skillFile.path, diagnostics), + auxiliaryFiles: Object.freeze(auxiliary), + })); + } + return Object.freeze(result.sort((left, right) => compareCodePoints(left.id, right.id))); +} diff --git a/packages/core/src/resources/extensions.ts b/packages/core/src/resources/extensions.ts new file mode 100644 index 0000000..357312b --- /dev/null +++ b/packages/core/src/resources/extensions.ts @@ -0,0 +1,370 @@ +/** Extension Resource Provider 串联发现、验证、构建与贡献收集。 */ +import type { + AcpluginExtension, + ExtensionSession, + ExtensionSubject, + PlatformContributor, + PlatformIntegrationDescription, + PackageContribution, +} from '../contracts/integrations.js'; +import type { JsonObject } from '../contracts/common.js'; +import type { CanonicalProject } from '../contracts/components.js'; +import type { CompilerService } from '../contracts/compiler.js'; +import type { + ExecutionService, + ModuleService, + SourceDirectoryRef, +} from '../contracts/services.js'; +import type { PlatformBasePackageSnapshot } from '../contracts/packages.js'; +import { AssetRegistry } from '../services/assets.js'; +import { dataArrayItems, dataObjectFields } from '../security/data-boundary.js'; +import { DiagnosticRegistry } from '../services/diagnostics.js'; +import { snapshotExtensionState } from '../services/extension-state.js'; +import { compareCodePoints } from '../security/path-policy.js'; +import { SourceRegistry } from '../services/sources.js'; + +/** Extension discover 完成后的 owner-bound State。 */ +export interface DiscoveredExtensionState { + readonly extension: AcpluginExtension; + readonly state: Readonly; +} + +/** Extension validate 完成后的 owner-bound State 与兼容主题。 */ +export interface ValidatedExtensionState { + readonly extension: AcpluginExtension; + readonly state: Readonly; + readonly subjects: readonly ExtensionSubject[]; +} + +/** 一个选中 Platform 对当前 Extension 的 Contributor 匹配结果。 */ +export interface ExtensionConsumer { + readonly platform: PlatformIntegrationDescription; + readonly contributor?: PlatformContributor; +} + +/** validate 后、build 前固定的 Extension consumer 计划。 */ +export interface ExtensionConsumerPlan { + readonly extension: AcpluginExtension; + readonly validated: Readonly; + readonly subjects: readonly ExtensionSubject[]; + readonly consumers: readonly ExtensionConsumer[]; + readonly requiresBuild: boolean; +} + +/** Extension build 完成后可并行交给全部 Contributor 的 State。 */ +export interface BuiltExtensionState { + readonly extension: AcpluginExtension; + readonly state: Readonly; + readonly subjects: readonly ExtensionSubject[]; +} + +/** + * 验证 subject/capability 使用稳定非空身份。 + * + * @param value 未受信任的身份值。 + * @param label 诊断字段标签。 + * @returns 合法原始文本。 + */ +function stableSubject(value: unknown, label: string): string { + if (typeof value !== 'string' || !/^[a-z0-9]+(?:[-.:/][a-z0-9]+)*$/u.test(value)) + throw new TypeError(`${label} must be a stable lowercase identifier.`); + return value; +} + +/** + * 复制、去歧义并排序 Extension subjects。 + * + * @param value validate 返回的未知 subjects。 + * @returns tuple 唯一的不可变 subjects。 + */ +function subjects(value: unknown): readonly ExtensionSubject[] { + if (!Array.isArray(value)) + throw new TypeError('Extension validation subjects must be an array.'); + /** subject ID 到完整声明的唯一映射。 */ + const entries = new Map(); + for (const [index, item] of [...value].entries()) { + if (typeof item !== 'object' || item === null || Array.isArray(item) + || Object.getPrototypeOf(item) !== Object.prototype + || Object.getOwnPropertySymbols(item).length > 0) { + throw new TypeError(`Extension validation subjects[${index}] must be a plain object.`); + } + /** Subject 字段只通过 descriptor 读取以避免 getter。 */ + const descriptors = Object.getOwnPropertyDescriptors(item); + if (Object.keys(descriptors).some(field => field !== 'subject' && field !== 'capabilities') + || Object.values(descriptors).some(descriptor => !('value' in descriptor))) { + throw new TypeError(`Extension validation subjects[${index}] has invalid fields.`); + } + /** subject 是所有 capability tuple 的稳定资源身份。 */ + const id = stableSubject(descriptors.subject?.value, 'Extension subject'); + if (!Array.isArray(descriptors.capabilities?.value)) + throw new TypeError(`Extension subject "${id}" capabilities must be an array.`); + /** capability 排序使声明顺序不影响后续兼容性覆盖。 */ + const capabilities = [...descriptors.capabilities.value].map(capability => stableSubject(capability, 'Extension capability')).sort(compareCodePoints); + if (capabilities.length === 0 || new Set(capabilities).size !== capabilities.length) + throw new TypeError(`Extension subject "${id}" capabilities must be non-empty and unique.`); + if (entries.has(id)) + throw new TypeError(`Extension subject "${id}" is duplicated.`); + entries.set(id, Object.freeze({ subject: id, capabilities: Object.freeze(capabilities) })); + } + return Object.freeze([...entries.values()].sort((left, right) => compareCodePoints(left.subject, right.subject))); +} + +/** + * 调用 Extension discover 并建立 State 数据边界。 + * + * @param options 当前 Extension Session 和 owner-scoped 服务。 + * @returns undefined 表示该 Extension 本轮无选中资源。 + */ +export async function discoverExtension(options: { + readonly extension: AcpluginExtension; + readonly session: ExtensionSession; + readonly roots: Readonly>; + readonly command: import('../contracts/config.js').ConfigCommand; + readonly mode: import('../contracts/config.js').BuildMode; + readonly sources: SourceRegistry; + readonly assets: AssetRegistry; + readonly modules: ModuleService; + readonly diagnostics: DiagnosticRegistry; +}): Promise | undefined> { + /** owner 同时绑定 Source/Asset/Diagnostic capability。 */ + const owner = `extension:${options.extension.id}`; + /** Context 外壳和 roots map 均不可被 Extension 改写。 */ + const context = Object.freeze({ + command: options.command, + mode: options.mode, + roots: Object.freeze({ ...options.roots }), + sources: options.sources.service(owner), + modules: options.modules, + diagnostics: options.diagnostics.service('discover', { owner, extension: options.extension.id }), + }); + /** Extension 原始返回值必须立即越过 State snapshot 边界。 */ + const discovered = await options.session.discover(context); + if (discovered === undefined) + return undefined; + return Object.freeze({ + extension: options.extension, + state: snapshotExtensionState(discovered, { + owner, + phase: 'discovered', + sources: options.sources, + assets: options.assets, + }), + }); +} + +/** + * 调用 Extension validate 并建立 validated State/subject 边界。 + * + * @param options 当前 discovered State、Project 和 Session。 + * @returns 不可变 validated State。 + */ +export async function validateExtension(options: { + readonly discovered: DiscoveredExtensionState; + readonly session: ExtensionSession; + readonly project: CanonicalProject; + readonly command: import('../contracts/config.js').ConfigCommand; + readonly mode: import('../contracts/config.js').BuildMode; + readonly sources: SourceRegistry; + readonly assets: AssetRegistry; + readonly diagnostics: DiagnosticRegistry; +}): Promise> { + /** 当前 Extension 稳定 ID 用于上下文和错误归属。 */ + const id = options.discovered.extension.id; + /** validated State 沿用同一 Extension owner。 */ + const owner = `extension:${id}`; + /** validate 只读取 immutable Project 与 discovered State。 */ + const output = await options.session.validate(Object.freeze({ + command: options.command, + mode: options.mode, + project: options.project, + diagnostics: options.diagnostics.service('validate', { owner, extension: id }), + }), options.discovered.state); + if (typeof output !== 'object' || output === null || Array.isArray(output) + || Object.getPrototypeOf(output) !== Object.prototype + || Object.getOwnPropertySymbols(output).length > 0) { + throw new TypeError(`Extension "${id}" validate output must be a plain object.`); + } + /** validate 输出字段通过 descriptor 校验且只允许 state/subjects。 */ + const fields = Object.getOwnPropertyDescriptors(output); + if (Object.keys(fields).sort().join(',') !== 'state,subjects' + || Object.values(fields).some(descriptor => !('value' in descriptor))) { + throw new TypeError(`Extension "${id}" validate output must contain state and subjects data fields.`); + } + return Object.freeze({ + extension: options.discovered.extension, + state: snapshotExtensionState(fields.state!.value as V, { + owner, + phase: 'validated', + sources: options.sources, + assets: options.assets, + }), + subjects: subjects(fields.subjects!.value), + }); +} + +/** + * 验证 Contributor definitions 并为全部选中 Platform 固定 consumer 计划。 + * + * @param options 当前 Extension、Session、validated State 和选中平台。 + * @returns 与 Platform 配置顺序无关的 frozen consumer plan。 + */ +export function preflightExtensionConsumers(options: { + readonly validated: ValidatedExtensionState; + readonly session: ExtensionSession; + readonly platforms: readonly PlatformIntegrationDescription[]; +}): ExtensionConsumerPlan { + /** Contributor array 自身也必须是无 accessor 的稠密 data array。 */ + const candidates = dataArrayItems(options.session.contributors, `Extension "${options.validated.extension.id}" contributors`); + /** Platform ID 索引拒绝一个 Extension 对同目标定义两个 Contributor。 */ + const contributors = new Map>(); + for (const [index, candidate] of candidates.entries()) { + /** Contributor 是唯一允许包含 contribute 行为的精确对象。 */ + const fields = dataObjectFields( + candidate, + new Set(['platform', 'platformApiVersion', 'contribute']), + `Extension "${options.validated.extension.id}" contributor[${index}]`, + ); + /** Contributor identity 是精确 Platform ID;API 不匹配是无效定义而非静默 unsupported。 */ + const platform = stableSubject(fields.platform?.value, 'Contributor Platform'); + if (fields.platformApiVersion?.value !== '1') + throw new TypeError(`Contributor "${platform}" must use Platform API version 1.`); + if (typeof fields.contribute?.value !== 'function') + throw new TypeError(`Contributor "${platform}" must provide a contribute function.`); + if (contributors.has(platform)) + throw new TypeError(`Extension "${options.validated.extension.id}" has duplicate Contributors for Platform "${platform}".`); + contributors.set(platform, Object.freeze({ + platform, + platformApiVersion: '1', + contribute: fields.contribute.value as PlatformContributor['contribute'], + })); + } + /** 选中平台排序使 consumer preflight 与作者配置顺序无关。 */ + const selected = [...options.platforms].sort((left, right) => compareCodePoints(left.id, right.id)); + if (new Set(selected.map(platform => platform.id)).size !== selected.length) + throw new TypeError('Selected Platform descriptions must be unique.'); + /** 每个 selected Platform 都得到匹配或缺失的显式 consumer slot。 */ + const consumers = selected.map(platform => Object.freeze({ + platform, + ...(contributors.get(platform.id) === undefined ? {} : { contributor: contributors.get(platform.id)! }), + })); + return Object.freeze({ + extension: options.validated.extension, + validated: options.validated.state, + subjects: options.validated.subjects, + consumers: Object.freeze(consumers), + requiresBuild: consumers.some(consumer => consumer.contributor !== undefined), + }); +} + +/** + * 只在至少一个选中 Platform 拥有 Contributor 时构建 Extension。 + * + * @param options consumer plan 与当前 Extension 的 owner-scoped Host 服务。 + * @returns frozen Built State;undefined 表示 preflight 已安全跳过 build。 + */ +export async function buildExtension(options: { + readonly plan: ExtensionConsumerPlan; + readonly session: ExtensionSession; + readonly project: CanonicalProject; + readonly command: import('../contracts/config.js').ConfigCommand; + readonly mode: import('../contracts/config.js').BuildMode; + readonly compiler: CompilerService; + readonly execution: ExecutionService; + readonly assets: AssetRegistry; + readonly sources: SourceRegistry; + readonly diagnostics: DiagnosticRegistry; +}): Promise | undefined> { + if (!options.plan.requiresBuild) + return undefined; + /** Extension ID 决定 owner-scoped Host capability。 */ + const id = options.plan.extension.id; + /** 同一 owner 贯穿 build State 与 Contribution。 */ + const owner = `extension:${id}`; + /** build Context 只提供当前 owner 的受管 Host 能力。 */ + const output = await options.session.build(Object.freeze({ + command: options.command, + mode: options.mode, + project: options.project, + compiler: options.compiler, + assets: options.assets.service(owner), + execution: options.execution, + diagnostics: options.diagnostics.service('compile', { owner, extension: id }), + }), options.plan.validated); + /** build 输出只包含 Built State,不允许在此修改 subjects。 */ + const fields = dataObjectFields(output, new Set(['state']), `Extension "${id}" build output`); + if (!Object.hasOwn(fields, 'state')) + throw new TypeError(`Extension "${id}" build output must contain a state data field.`); + return Object.freeze({ + extension: options.plan.extension, + state: snapshotExtensionState(fields.state!.value as B, { + owner, + phase: 'built', + sources: options.sources, + assets: options.assets, + }), + subjects: options.plan.subjects, + }); +} + +/** + * 对同一 frozen base Package 并行收集 Extension Contributions。 + * + * @param options 当前 Platform、Project、consumer plans 和 Built States。 + * @returns completion order 无关的 owner-bound Contributions。 + */ +export async function collectExtensionContributions(options: { + readonly platform: PlatformIntegrationDescription; + readonly base: PlatformBasePackageSnapshot; + readonly project: CanonicalProject; + readonly command: import('../contracts/config.js').ConfigCommand; + readonly mode: import('../contracts/config.js').BuildMode; + readonly plans: readonly ExtensionConsumerPlan[]; + readonly built: readonly BuiltExtensionState[]; + readonly assets: AssetRegistry; + readonly diagnostics: DiagnosticRegistry; +}): Promise { + /** Built State 只按 Extension ID 配对,不暴露给其他 Extension。 */ + const builtByExtension = new Map(options.built.map(state => [state.extension.id, state])); + /** 所有 Contributor promises 在读取同一 base 后并行启动。 */ + const tasks = options.plans.map(async (plan) => { + /** Context capability 与当前 Extension owner 绑定。 */ + const owner = `extension:${plan.extension.id}`; + /** 当前 Platform 只读取 plan 中自己的 consumer slot。 */ + const consumer = plan.consumers.find(item => item.platform.id === options.platform.id); + if (consumer === undefined) + throw new TypeError(`Extension consumer plan does not include selected Platform "${options.platform.id}".`); + if (consumer.contributor === undefined) { + /** 缺少 Contributor 是每个已验证 subject/capability 的显式 unsupported。 */ + const compatibility = plan.subjects.flatMap(subject => subject.capabilities.map(capability => Object.freeze({ + subject: subject.subject, + capability, + level: 'unsupported' as const, + reason: `Extension "${plan.extension.id}" has no compatible contributor for Platform "${options.platform.id}".`, + }))); + return Object.freeze({ + owner, + subjects: plan.subjects, + contribution: Object.freeze({ compatibility: Object.freeze(compatibility) }), + }); + } + /** 匹配 Contributor 时必须已有一次共享 Built State。 */ + const built = builtByExtension.get(plan.extension.id); + if (built === undefined) + throw new TypeError(`Extension "${plan.extension.id}" requires Built State for Platform "${options.platform.id}".`); + /** 所有 Contributor 获得同一个 base object identity,且无其他 Contribution 可见。 */ + const contribution = await consumer.contributor.contribute(Object.freeze({ + command: options.command, + mode: options.mode, + platform: options.platform, + project: options.project, + base: options.base, + assets: options.assets.service(owner), + diagnostics: options.diagnostics.service('contribute', { + owner, extension: plan.extension.id, platform: options.platform.id, + }), + }), built.state); + return Object.freeze({ owner, subjects: plan.subjects, contribution: contribution as PackageContribution }); + }); + /** Promise.all 保留输入槽位,但 merge 只接受 owner-sorted 无序集合。 */ + return Object.freeze(await Promise.all(tasks)); +} diff --git a/packages/core/src/resources/project-graph.ts b/packages/core/src/resources/project-graph.ts new file mode 100644 index 0000000..e9a979c --- /dev/null +++ b/packages/core/src/resources/project-graph.ts @@ -0,0 +1,28 @@ +import type { + CanonicalProject, + NodeRuntimeResource, + PublicResourceFile, +} from '../contracts/components.js'; + +/** + * 将三个 Framework Provider 的独立结果组装为唯一 Project Graph。 + * + * @param canonical 已验证 canonical Component graph。 + * @param publicFiles Public SourceAsset mappings。 + * @param runtime 可选内建 Runtime resource。 + * @returns 不含工程根或物理路径的不可变 Project。 + */ +export function assembleProjectGraph( + canonical: CanonicalProject, + publicFiles: readonly PublicResourceFile[], + runtime?: NodeRuntimeResource, +): CanonicalProject { + return Object.freeze({ + metadata: canonical.metadata, + commands: canonical.commands, + skills: canonical.skills, + agents: canonical.agents, + publicFiles, + ...(runtime === undefined ? {} : { runtime }), + }); +} diff --git a/packages/core/src/resources/public.ts b/packages/core/src/resources/public.ts new file mode 100644 index 0000000..0f43ab7 --- /dev/null +++ b/packages/core/src/resources/public.ts @@ -0,0 +1,189 @@ +/** Public Resource Provider 发现并签发静态公开文件。 */ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { PublicResourceFile } from '../contracts/components.js'; +import type { + SourceAssetRef, + SourceFileRef, +} from '../contracts/services.js'; +import { AssetRegistry } from '../services/assets.js'; +import type { ResolvedKernelConfig, ResolvedPublicCopyRule } from '../config/resolver.js'; +import { DiagnosticRegistry } from '../services/diagnostics.js'; +import { compareCodePoints, safeRelativePath, sourceCollisionKey } from '../security/path-policy.js'; +import { SourceRegistry } from '../services/sources.js'; +import { WatchRegistry, type WatchObservation } from '../services/watch.js'; + +/** Public 收集阶段带完整来源 identity 的内部记录。 */ +interface PublicSource { + readonly path: string; + readonly source: SourceFileRef; + readonly asset: SourceAssetRef; +} + +/** + * 返回路径是否不存在。 + * + * @param file 候选物理路径。 + * @returns ENOENT 为 true。 + */ +async function missing(file: string): Promise { + return fs.lstat(file).then(() => false, error => (error as NodeJS.ErrnoException).code === 'ENOENT'); +} + +/** + * 签发 Public source root 并递归产生映射文件。 + * + * @param rule 当前精确 copy rule;undefined 表示全树复制。 + * @param config Kernel 私有配置。 + * @param sources Source Registry。 + * @param assets Asset Registry。 + * @param diagnostics 当前诊断集合。 + * @returns 当前来源映射产生的文件。 + */ +async function collect( + rule: ResolvedPublicCopyRule | undefined, + config: ResolvedKernelConfig, + sources: SourceRegistry, + assets: AssetRegistry, + diagnostics: DiagnosticRegistry, +): Promise { + /** Public 资源的固定 issuer 不能由配置覆盖。 */ + const owner = 'framework:public'; + /** 精确规则优先,否则使用完整 Public directory。 */ + const source = rule?.source ?? config.public.directory; + /** 缺省 public root 不存在时静默;显式 copy 缺失必须失败。 */ + if (await missing(source)) { + if (rule !== undefined) { + diagnostics.report('discover', { + code: 'PUBLIC_SOURCE_MISSING', severity: 'error', message: 'Public copy source does not exist.', + location: { path: path.relative(config.projectRoot, source).split(path.sep).join('/') }, + }, { owner }); + } + return Object.freeze([]); + } + /** lstat 在读取前拒绝根节点自身的符号链接。 */ + const stat = await fs.lstat(source); + if (stat.isSymbolicLink() || (!stat.isFile() && !stat.isDirectory())) { + diagnostics.report('discover', { + code: 'PUBLIC_SOURCE_INVALID', severity: 'error', message: 'Public source must be a regular file or directory without symbolic links.', + location: { path: path.relative(config.projectRoot, source).split(path.sep).join('/') }, + }, { owner }); + return Object.freeze([]); + } + /** Source Registry root 只能是目录;单文件 rule 使用其父目录作为最小授权 root。 */ + const physicalRoot = stat.isDirectory() ? source : path.dirname(source); + /** Source Registry 只签发目录能力。 */ + let root: import('../contracts/services.js').SourceDirectoryRef; + try { + root = await sources.issueRoot(owner, physicalRoot); + /** 目录 mapping 校验完整子树;单文件 mapping 不读取其未授权 siblings。 */ + if (stat.isDirectory()) + await sources.validateTree(owner, root); + } catch { + diagnostics.report('discover', { + code: 'PUBLIC_SOURCE_INVALID', severity: 'error', message: 'Public source tree contains an unsafe entry.', + location: { path: path.relative(config.projectRoot, source).split(path.sep).join('/') }, + }, { owner }); + return Object.freeze([]); + } + /** 后续来源访问全部绑定 framework:public owner。 */ + const sourceService = sources.service(owner); + /** Asset 转换保留原始 Public provenance 和 mode。 */ + const assetService = assets.service(owner); + /** 单文件映射直接签发;目录映射递归展开。 */ + const entries: { readonly file: SourceFileRef; readonly relative: string }[] = []; + if (stat.isFile()) { + entries.push(Object.freeze({ file: await sourceService.file(root, path.basename(source)), relative: '' })); + } else { + for (const entry of await sourceService.list(root, { recursive: true })) { + if (entry.type === 'file') { + entries.push(Object.freeze({ + file: entry.file, + relative: entry.path.slice(`${root.path}/`.length), + })); + } + } + } + /** target 指向文件时保持精确路径,指向目录时追加完整相对后代。 */ + const target = rule?.to ?? ''; + /** 当前 rule 的合法输出集合。 */ + const result: PublicSource[] = []; + for (const entry of entries) { + /** 目录来源追加后代路径,单文件来源精确使用 to。 */ + const mapped = entry.relative.length === 0 + ? target + : target.length === 0 ? entry.relative : `${target}/${entry.relative}`; + try { + result.push(Object.freeze({ + path: safeRelativePath(mapped), + source: entry.file, + asset: await assetService.fromSource(entry.file), + })); + } catch { + diagnostics.report('discover', { + code: 'PUBLIC_TARGET_INVALID', severity: 'error', message: 'Public target must be a non-empty package-relative POSIX path.', + location: { path: entry.file.path }, + }, { owner }); + } + } + return Object.freeze(result); +} + +/** + * 发现 Public exact mapping 并签发 SourceAssetRef。 + * + * @param options 当前 BuildSession registries 与配置。 + * @returns 按 package-relative path 排序的 Public 资源。 + */ +export async function discoverPublicResources(options: { + readonly config: ResolvedKernelConfig; + readonly sources: SourceRegistry; + readonly assets: AssetRegistry; + readonly watch: WatchRegistry; + readonly diagnostics: DiagnosticRegistry; +}): Promise { + if (!options.config.public.enabled) + return Object.freeze([]); + /** 无 copy rules 时用 undefined sentinel 表示完整树映射。 */ + const rules = options.config.public.copy ?? [undefined]; + /** Public 来源 watch 使用每条精确 source 的最近现有目录。 */ + const observations: WatchObservation[] = []; + for (const rule of rules) { + /** 每条精确 source 独立寻找可观察祖先。 */ + const source = rule?.source ?? options.config.public.directory; + /** 尚不存在的文件逐级回退到现有目录。 */ + let candidate = source; + while (candidate !== options.config.projectRoot) { + /** Watch root 本身也不能是作者 symlink。 */ + const stat = await fs.lstat(candidate).catch(() => undefined); + if (stat?.isDirectory() === true && !stat.isSymbolicLink()) { + observations.push(Object.freeze({ path: candidate, type: 'directory' as const })); + break; + } + candidate = path.dirname(candidate); + } + } + if (observations.length > 0) + await options.watch.replace('framework:public', 'resource/public', observations); + /** 独立 copy rule 可并行展开;碰撞在集中阶段确定性处理。 */ + const discovered = (await Promise.all(rules.map(rule => collect(rule, options.config, options.sources, options.assets, options.diagnostics)))).flat(); + /** 折叠目标到首次来源的索引用于稳定报告冲突。 */ + const targets = new Map(); + /** 最终暴露给 Project Graph 的精简资源列表。 */ + const result: PublicResourceFile[] = []; + for (const file of discovered.sort((left, right) => compareCodePoints(left.path, right.path) || compareCodePoints(left.source.path, right.source.path))) { + /** 目标 collision key 同时折叠大小写和 Unicode NFC。 */ + const key = sourceCollisionKey(file.path); + /** existing 用于 related location 和 add-only 冲突判定。 */ + const existing = targets.get(key); + if (existing !== undefined) { + options.diagnostics.report('validate', { + code: 'PUBLIC_TARGET_COLLISION', severity: 'error', message: `Public target "${file.path}" has multiple sources.`, location: { path: file.source.path }, + }, { owner: 'framework:public', related: [{ path: existing.source.path }] }); + continue; + } + targets.set(key, file); + result.push(Object.freeze({ path: file.path, asset: file.asset })); + } + return Object.freeze(result); +} diff --git a/packages/core/src/resources/registry.ts b/packages/core/src/resources/registry.ts new file mode 100644 index 0000000..6ce8dc2 --- /dev/null +++ b/packages/core/src/resources/registry.ts @@ -0,0 +1,229 @@ +/** Resource Registry 统一分配 Core 与 Extension 作者来源根。 */ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { AcpluginExtension } from '../contracts/integrations.js'; +import type { SourceDirectoryRef } from '../contracts/services.js'; +import { DiagnosticRegistry } from '../services/diagnostics.js'; +import { compareCodePoints } from '../security/path-policy.js'; +import { SourceRegistry } from '../services/sources.js'; +import { WatchRegistry } from '../services/watch.js'; +import type { ResolvedKernelConfig } from '../config/resolver.js'; + +/** Framework 内建来源根名称。 */ +export type CanonicalResourceRoot = 'commands' | 'skills' | 'agents'; + +/** Resource Registry 完成 root 所有权分配后的不可变集合。 */ +export interface ResourceClaims { + readonly canonical: Readonly>>; + readonly runtime?: SourceDirectoryRef; + readonly extensions: Readonly>>>; +} + +/** root claim 的内部 owner 记录。 */ +interface RootClaim { + readonly owner: string; + readonly kind: 'canonical' | 'runtime' | 'extension'; + readonly extension?: AcpluginExtension; +} + +/** + * 为所有已配置 Extension 建立稳定、不可变的 root snapshot。 + * + * @param extensions 已配置 Extension definitions。 + * @param discovered 已发现的可选 root records。 + * @returns 按 Extension ID 排序且不暴露可变 Map 的 plain-data record。 + */ +function extensionClaims( + extensions: readonly AcpluginExtension[], + discovered: ReadonlyMap>> = new Map(), +): Readonly>>> { + return Object.freeze(Object.fromEntries( + [...extensions] + .sort((left, right) => compareCodePoints(left.id, right.id)) + .map(extension => [extension.id, Object.freeze({ ...(discovered.get(extension.id) ?? {}) })]), + )); +} + +/** + * 判断目录是否存在任何直接或后代内容。 + * + * @param directory 未被 claim 的候选目录。 + * @returns 空目录为 false,首个后代存在即为 true。 + */ +async function hasContent(directory: string): Promise { + /** 一级存在任意目录项即可证明 root 并非无意留下的空目录。 */ + const entries = await fs.readdir(directory, { withFileTypes: true }); + if (entries.length === 0) + return false; + return true; +} + +/** + * 返回待观察路径的最近现有普通目录。 + * + * @param projectRoot 工程根。 + * @param desired 可能尚不存在的来源目录。 + * @returns Dev watcher 可以实际注册的工程内目录。 + */ +async function nearestExistingDirectory(projectRoot: string, desired: string): Promise { + /** 从目标向工程根回溯,确保空/缺失 root 仍可触发 rebuild。 */ + let candidate = desired; + while (candidate !== projectRoot) { + /** lstat 避免把 symlink 祖先登记为可信 Watch root。 */ + const stat = await fs.lstat(candidate).catch(() => undefined); + if (stat?.isDirectory() === true && !stat.isSymbolicLink()) + return candidate; + candidate = path.dirname(candidate); + } + return projectRoot; +} + +/** BuildSession 中唯一的 source-root ownership registry。 */ +export class ResourceRegistry { + /** Kernel 私有最终配置。 */ + readonly #config: ResolvedKernelConfig; + /** 当前 Session SourceRef issuer。 */ + readonly #sources: SourceRegistry; + /** 当前 Session Watch Registry。 */ + readonly #watch: WatchRegistry; + /** 当前 Session 稳定诊断集合。 */ + readonly #diagnostics: DiagnosticRegistry; + + /** + * 创建 Resource Registry。 + * + * @param options 当前 BuildSession 依赖。 + */ + constructor(options: { + readonly config: ResolvedKernelConfig; + readonly sources: SourceRegistry; + readonly watch: WatchRegistry; + readonly diagnostics: DiagnosticRegistry; + }) { + this.#config = options.config; + this.#sources = options.sources; + this.#watch = options.watch; + this.#diagnostics = options.diagnostics; + } + + /** + * 建立内建与 Extension root claims 并拒绝未知内容。 + * + * @returns 只包含当前实际存在目录的不可变 SourceRef 集合。 + */ + async claim(): Promise { + /** 所有声明在接触文件系统前先完成冲突检查。 */ + const claims = new Map(); + for (const root of ['commands', 'skills', 'agents'] as const) + claims.set(root, Object.freeze({ owner: 'framework:canonical', kind: 'canonical' as const })); + if (this.#config.runtime.enabled) + claims.set('runtime', Object.freeze({ owner: 'framework:node-runtime', kind: 'runtime' as const })); + for (const extension of [...this.#config.extensions].sort((left, right) => compareCodePoints(left.id, right.id))) { + for (const root of extension.resourceRoots) { + /** 第一个 claim 固定 owner,后续同名声明只产生诊断。 */ + const existing = claims.get(root); + if (existing !== undefined) { + this.#diagnostics.report('setup', { + code: 'RESOURCE_ROOT_CONFLICT', + severity: 'error', + message: `Source root "${root}" is claimed by both ${existing.owner} and extension:${extension.id}.`, + location: { path: `${path.relative(this.#config.projectRoot, this.#config.srcDirectory).split(path.sep).join('/')}/${root}` }, + }); + continue; + } + claims.set(root, Object.freeze({ owner: `extension:${extension.id}`, kind: 'extension' as const, extension })); + } + } + + /** srcDir 不存在时观察最近祖先并返回空资源图。 */ + const srcStat = await fs.lstat(this.#config.srcDirectory).catch(() => undefined); + /** Watch Registry 接收实际存在的最近目录而非虚构路径。 */ + const watchedSource = await nearestExistingDirectory(this.#config.projectRoot, this.#config.srcDirectory); + await this.#watch.replace('framework:resource', 'resource/src', [{ path: watchedSource, type: 'directory' }]); + if (srcStat === undefined) + return Object.freeze({ canonical: Object.freeze({}), extensions: extensionClaims(this.#config.extensions) }); + if (!srcStat.isDirectory() || srcStat.isSymbolicLink()) { + this.#diagnostics.report('discover', { + code: 'SOURCE_ROOT_INVALID', severity: 'error', message: 'srcDir must be a regular directory without symbolic links.', + location: { path: path.relative(this.#config.projectRoot, this.#config.srcDirectory).split(path.sep).join('/') }, + }); + return Object.freeze({ canonical: Object.freeze({}), extensions: extensionClaims(this.#config.extensions) }); + } + /** framework owner 用于安全枚举 srcDir 一级目录。 */ + let sourceRoot: SourceDirectoryRef; + try { + sourceRoot = await this.#sources.issueRoot('framework:resource', this.#config.srcDirectory); + } catch { + this.#diagnostics.report('discover', { + code: 'SOURCE_ROOT_INVALID', severity: 'error', message: 'srcDir failed the author source boundary.', + location: { path: path.relative(this.#config.projectRoot, this.#config.srcDirectory).split(path.sep).join('/') }, + }); + return Object.freeze({ canonical: Object.freeze({}), extensions: extensionClaims(this.#config.extensions) }); + } + /** 一级枚举通用拒绝 symlink/special/collision。 */ + let entries: Awaited['list']>>; + try { + entries = await this.#sources.service('framework:resource').list(sourceRoot); + } catch { + this.#diagnostics.report('discover', { + code: 'SOURCE_ROOT_CONTENT_INVALID', severity: 'error', message: 'srcDir contains an unsafe or ambiguous source entry.', + location: { path: sourceRoot.path }, + }); + return Object.freeze({ canonical: Object.freeze({}), extensions: extensionClaims(this.#config.extensions) }); + } + /** 各 owner 最终实际存在的 root refs。 */ + const canonical: Partial> = {}; + /** Extension ID 到其现有 root refs。 */ + const extensionRoots = new Map>(); + /** 当前可选 Runtime root。 */ + let runtime: SourceDirectoryRef | undefined; + for (const entry of entries) { + /** 一级名称直接映射到此前完成冲突校验的 claim。 */ + const claim = claims.get(entry.name); + if (entry.type === 'file') { + this.#diagnostics.report('discover', { + code: 'RESOURCE_ROOT_UNKNOWN', severity: 'error', message: `srcDir direct file "${entry.name}" has no Resource owner.`, location: { path: entry.path }, + }); + continue; + } + if (claim === undefined) { + if (await hasContent(path.join(this.#config.srcDirectory, entry.name))) { + this.#diagnostics.report('discover', { + code: 'RESOURCE_ROOT_UNKNOWN', severity: 'error', message: `Non-empty source root "${entry.name}" has no configured Resource owner.`, location: { path: entry.path }, + }); + } + continue; + } + /** 每个 Resource owner 收到以自身身份签发的独占 root ref。 */ + try { + /** 物理 root 永远由最终 srcDirectory 和直接子目录组成。 */ + const physical = path.join(this.#config.srcDirectory, entry.name); + /** Source Registry 使用 owner+Session 身份签发 root。 */ + const root = await this.#sources.issueRoot(claim.owner, physical); + await this.#sources.validateTree(claim.owner, root); + if (claim.kind === 'canonical') + canonical[entry.name as CanonicalResourceRoot] = root; + else if (claim.kind === 'runtime') + runtime = root; + else { + /** Extension ID 是最终 roots snapshot 的第一层稳定键。 */ + const id = claim.extension!.id; + /** 同一 Extension 可声明多个互不重叠的一级 root。 */ + const roots = extensionRoots.get(id) ?? {}; + roots[entry.name] = root; + extensionRoots.set(id, roots); + } + } catch { + this.#diagnostics.report('discover', { + code: 'RESOURCE_ROOT_CONTENT_INVALID', severity: 'error', message: `Source root "${entry.name}" contains an unsafe entry.`, location: { path: entry.path }, + }); + } + } + /** 即使某 Extension root 缺失,也用冻结空对象保留 Extension ID 的稳定索引。 */ + return Object.freeze({ + canonical: Object.freeze({ ...canonical }), + ...(runtime === undefined ? {} : { runtime }), + extensions: extensionClaims(this.#config.extensions, extensionRoots), + }); + } +} diff --git a/packages/core/src/resources/runtime/paths.ts b/packages/core/src/resources/runtime/paths.ts new file mode 100644 index 0000000..813645a --- /dev/null +++ b/packages/core/src/resources/runtime/paths.ts @@ -0,0 +1,19 @@ +/** Runtime entry ID 与其他 Core 稳定资源 ID 使用相同 lowercase-kebab 规则。 */ +const RUNTIME_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + +/** 验证公开路径 helper 不会把任意文本变成 Package 路径。 */ +function runtimeId(id: string): string { + if (typeof id !== 'string' || !RUNTIME_ID.test(id)) + throw new TypeError('Runtime entry id must use lowercase kebab-case.'); + return id; +} + +/** 返回 Runtime entry 在所有支持 Platform 中的固定主 Bundle 路径。 */ +export function nodeRuntimeArtifactPath(id: string): `runtime/${string}/main.mjs` { + return `runtime/${runtimeId(id)}/main.mjs`; +} + +/** 返回 Runtime entry 存在第三方依赖时使用的固定许可证路径。 */ +export function nodeRuntimeLicensesArtifactPath(id: string): `runtime/${string}/THIRD_PARTY_LICENSES.txt` { + return `runtime/${runtimeId(id)}/THIRD_PARTY_LICENSES.txt`; +} diff --git a/packages/core/src/resources/runtime/provider.ts b/packages/core/src/resources/runtime/provider.ts new file mode 100644 index 0000000..8124a4c --- /dev/null +++ b/packages/core/src/resources/runtime/provider.ts @@ -0,0 +1,250 @@ +/** Core Node Runtime Provider 发现并单次构建每个 Runtime 入口。 */ +import type { CompilerService } from '../../contracts/compiler.js'; +import type { + GeneratedAssetRef, + SourceDirectoryRef, +} from '../../contracts/services.js'; +import type { NodeRuntimeResource } from '../../contracts/components.js'; +import type { + PackageContribution, + PlatformIntegrationDescription, +} from '../../contracts/integrations.js'; +import type { ResolvedRuntimeConfig } from '../../config/resolver.js'; +import { DiagnosticRegistry } from '../../services/diagnostics.js'; +import { compareCodePoints, safeRelativePath, sourceCollisionKey } from '../../security/path-policy.js'; +import { SourceRegistry } from '../../services/sources.js'; +import { + nodeRuntimeArtifactPath, + nodeRuntimeLicensesArtifactPath, +} from './paths.js'; + +/** Runtime 允许成为 executable entry 的源码扩展名。 */ +const RUNTIME_EXTENSIONS = ['.tsx', '.mts', '.cts', '.jsx', '.mjs', '.cjs', '.ts', '.js'] as const; + +/** TypeScript declaration 永远不是 Runtime entry。 */ +const DECLARATION = /\.d\.(?:ts|mts|cts)$/u; + +/** Runtime ID 规范规则。 */ +const RUNTIME_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + +/** 单个已编译 Runtime entry 的 Core-owned AssetRefs。 */ +export interface BuiltNodeRuntimeEntry { + readonly id: string; + readonly kind: 'executable' | 'module'; + readonly main: GeneratedAssetRef; + readonly licenses?: GeneratedAssetRef; +} + +/** 一次 portable-node Job 产生的全部 Runtime Built State。 */ +export interface BuiltNodeRuntime { + readonly entries: readonly BuiltNodeRuntimeEntry[]; +} + +/** @returns Platform 是否声明精确 Plugin-local Node 20 ESM 能力。 */ +export function platformSupportsNodeRuntime(platform: PlatformIntegrationDescription): boolean { + /** capability 是 Platform setup 前已复制冻结的纯 JSON 数据。 */ + const capability = platform.capabilities?.nodeRuntime; + return capability?.target === 'node20' && capability.format === 'esm' && capability.root === 'plugin'; +} + +/** 通过 Core 唯一 portable-node Compiler Service 一次编译全部 Runtime entry。 */ +export async function buildNodeRuntime( + resource: NodeRuntimeResource, + compiler: CompilerService, +): Promise { + /** entries 保持 Provider 已固定的稳定 ID 顺序,并由 kind 决定主文件 mode。 */ + const entries = Object.fromEntries(resource.entries.map(entry => [entry.id, Object.freeze({ + type: 'source' as const, + source: entry.source, + mode: entry.kind === 'executable' ? 0o755 as const : 0o644 as const, + })])); + /** 所有入口属于一个逻辑 Job,Host 内部仍逐 entry 生成独立 Bundle。 */ + const result = await compiler.compile({ + id: 'node-runtime', + profile: 'portable-node', + entries: Object.freeze(entries), + ...(resource.compile === undefined ? {} : { options: resource.compile }), + }); + /** outputId 将 Compiler 结果确定性归组回 canonical Runtime entry。 */ + const built = resource.entries.map((entry) => { + /** outputs 只读取当前 entry 的 Host result 槽位。 */ + const outputs = result.outputs.filter(output => output.outputId === entry.id); + /** 每个 entry 必须恰好拥有固定 main Chunk。 */ + const mains = outputs.filter(output => output.type === 'chunk' && output.fileName === 'main.mjs' && output.isEntry); + /** license 仅在实际包含第三方依赖时存在。 */ + const licenses = outputs.filter(output => output.type === 'licenses' && output.fileName === 'THIRD_PARTY_LICENSES.txt'); + if (mains.length !== 1 || licenses.length > 1 || outputs.length !== mains.length + licenses.length) + throw new Error(`Compiler returned an invalid Runtime output set for "${entry.id}".`); + return Object.freeze({ + id: entry.id, + kind: entry.kind, + main: mains[0]!.asset, + ...(licenses[0] === undefined ? {} : { licenses: licenses[0].asset }), + }); + }); + return Object.freeze({ entries: Object.freeze(built) }); +} + +/** 为一个 Platform 建立 capability-driven Runtime add-only Contribution。 */ +export function nodeRuntimeContribution( + resource: NodeRuntimeResource, + built: BuiltNodeRuntime | undefined, + platform: PlatformIntegrationDescription, +): PackageContribution { + /** supported 决定是否继承 Bundle;不支持的平台只获得显式 compatibility。 */ + const supported = platformSupportsNodeRuntime(platform); + if (supported && built === undefined) + throw new Error('Supported Platform requires compiled Node Runtime state.'); + /** Built State 必须精确覆盖全部 canonical entry。 */ + const builtById = new Map((built?.entries ?? []).map(entry => [entry.id, entry])); + if (supported && (builtById.size !== resource.entries.length + || resource.entries.some(entry => !builtById.has(entry.id)))) { + throw new Error('Compiled Node Runtime state does not cover every entry.'); + } + /** 同一 Built AssetRef 被所有支持 Platform 原样继承。 */ + const assets = supported + ? resource.entries.flatMap((entry) => { + /** output 必须已由上面的完整覆盖校验证明存在。 */ + const output = builtById.get(entry.id)!; + return [ + Object.freeze({ path: nodeRuntimeArtifactPath(entry.id), asset: output.main }), + ...(output.licenses === undefined + ? [] + : [Object.freeze({ path: nodeRuntimeLicensesArtifactPath(entry.id), asset: output.licenses })]), + ]; + }) + : []; + /** Runtime compatibility 由 Framework 而非 Platform converter 统一生成。 */ + const compatibility = resource.entries.map(entry => Object.freeze({ + subject: `runtime:${entry.id}`, + capability: 'node20-esm', + level: supported ? 'native' as const : 'unsupported' as const, + reason: supported + ? 'The platform can install and execute the bundled Node.js runtime.' + : 'The platform does not provide a stable Plugin-local Node.js runtime.', + })); + return Object.freeze({ + assets: Object.freeze(assets), + compatibility: Object.freeze(compatibility), + }); +} + +/** @returns 文件名匹配的最长 Runtime 扩展名。 */ +function extension(file: string): typeof RUNTIME_EXTENSIONS[number] | undefined { + return RUNTIME_EXTENSIONS.find(candidate => file.endsWith(candidate)); +} + +/** + * 提交 Runtime Provider 诊断。 + * + * @param diagnostics 当前诊断集合。 + * @param code 稳定诊断码。 + * @param message 稳定信息。 + * @param location 工程相对路径。 + */ +function error(diagnostics: DiagnosticRegistry, code: string, message: string, location: string): void { + diagnostics.report('discover', { code, severity: 'error', message, location: { path: location } }, { owner: 'framework:node-runtime' }); +} + +/** + * 只把合法 runtime-relative entry 投影为诊断位置。 + * + * @param root Runtime root 的安全报告路径。 + * @param entry 仍可能绕过 config resolver 的 entry 输入。 + * @returns 合法精确位置,或不泄露越界语法的 Runtime root。 + */ +function runtimeLocation(root: SourceDirectoryRef, entry: unknown): string { + try { + return `${root.path}/${safeRelativePath(entry)}`; + } catch { + return root.path; + } +} + +/** + * 发现 Runtime auto/explicit entry model。 + * + * @param options Runtime root、配置和 registries。 + * @returns 不含物理路径的 Runtime resource;无入口时 undefined。 + */ +export async function discoverNodeRuntime(options: { + readonly root?: SourceDirectoryRef; + readonly config: ResolvedRuntimeConfig; + readonly sources: SourceRegistry; + readonly diagnostics: DiagnosticRegistry; +}): Promise { + if (!options.config.enabled || options.root === undefined) + return undefined; + /** Runtime 只能使用 Framework 固定 owner 的 Source Service。 */ + const sources = options.sources.service('framework:node-runtime'); + /** 有效入口先累积,最后按 ID 排序并冻结。 */ + const entries: { readonly id: string; readonly kind: 'executable' | 'module'; readonly source: import('../../contracts/services.js').SourceFileRef }[] = []; + /** ID 的 NFC/case fold 防止跨文件系统 Asset 路径冲突。 */ + const ids = new Map(); + if (options.config.entries === undefined) { + /** 自动模式只枚举 Runtime root 的直接子项。 */ + for (const entry of await sources.list(options.root)) { + if (entry.type === 'directory') + continue; + if (DECLARATION.test(entry.name)) + continue; + /** 最长匹配避免把 .mts 等误拆为普通文件名后缀。 */ + const suffix = extension(entry.name); + if (suffix === undefined) { + error(options.diagnostics, 'RUNTIME_SOURCE_UNSUPPORTED', 'Runtime root direct files must be executable TypeScript or JavaScript sources.', entry.path); + continue; + } + /** 自动入口 ID 直接来自去除源码扩展名后的文件名。 */ + const id = entry.name.slice(0, -suffix.length); + if (!RUNTIME_ID.test(id)) { + error(options.diagnostics, 'RUNTIME_ENTRY_ID_INVALID', `Runtime entry ID "${id}" must use lowercase kebab-case.`, entry.path); + continue; + } + /** case/NFC key 模拟最严格目标文件系统。 */ + const key = sourceCollisionKey(id); + if (ids.has(key)) { + error(options.diagnostics, 'RUNTIME_ENTRY_CONFLICT', `Runtime entry ID "${id}" conflicts with another source.`, entry.path); + continue; + } + ids.set(key, entry.path); + entries.push(Object.freeze({ id, kind: 'executable' as const, source: entry.file })); + } + } else { + for (const id of Object.keys(options.config.entries).sort(compareCodePoints)) { + /** 显式入口读取最终冻结配置而不是作者原始对象。 */ + const input = options.config.entries[id]!; + /** 诊断位置必须先通过安全路径投影。 */ + const location = runtimeLocation(options.root, input.entry); + /** 显式 ID 也使用相同的跨文件系统碰撞规则。 */ + const key = sourceCollisionKey(id); + if (ids.has(key)) { + error(options.diagnostics, 'RUNTIME_ENTRY_CONFLICT', `Runtime entry ID "${id}" conflicts after case or Unicode normalization.`, location); + continue; + } + if (!RUNTIME_ID.test(id)) { + error(options.diagnostics, 'RUNTIME_ENTRY_ID_INVALID', `Runtime entry ID "${id}" must use lowercase kebab-case.`, location); + continue; + } + if (extension(input.entry) === undefined || DECLARATION.test(input.entry)) { + error(options.diagnostics, 'RUNTIME_SOURCE_UNSUPPORTED', `Runtime entry "${id}" must reference executable TypeScript or JavaScript.`, location); + continue; + } + try { + /** 精确 entry 最终仍由 Source Registry 拒绝逃逸、symlink 和特殊文件。 */ + const source = await sources.file(options.root, input.entry); + ids.set(key, source.path); + entries.push(Object.freeze({ id, kind: input.kind, source })); + } catch { + error(options.diagnostics, 'RUNTIME_ENTRY_MISSING', `Runtime entry "${id}" is not a usable source file.`, location); + } + } + } + if (entries.length === 0) + return undefined; + entries.sort((left, right) => compareCodePoints(left.id, right.id)); + return Object.freeze({ + target: 'node20', + entries: Object.freeze(entries), + ...(options.config.compile === undefined ? {} : { compile: options.config.compile }), + }); +} diff --git a/packages/core/src/security/data-boundary.ts b/packages/core/src/security/data-boundary.ts new file mode 100644 index 0000000..e99b08e --- /dev/null +++ b/packages/core/src/security/data-boundary.ts @@ -0,0 +1,64 @@ +/** 安全边界已验证 data property 的 descriptor 形状。 */ +export type DataPropertyDescriptor = PropertyDescriptor & { readonly value: unknown }; + +/** + * 验证 Integration 返回的是仅含 data property 的普通对象。 + * + * @param value 未受信任对象。 + * @param allowed 允许出现的完整字段集合。 + * @param label 稳定诊断标签。 + * @returns 不会在后续读取时执行 getter 的字段 descriptor。 + */ +export function dataObjectFields( + value: unknown, + allowed: ReadonlySet, + label: string, +): Readonly> { + if (typeof value !== 'object' || value === null || Array.isArray(value)) + throw new TypeError(`${label} must be a plain object.`); + /** null-prototype records 与 object literal 都属于无行为数据容器。 */ + const prototype = Object.getPrototypeOf(value); + if ((prototype !== Object.prototype && prototype !== null) || Object.getOwnPropertySymbols(value).length > 0) + throw new TypeError(`${label} must be a plain object without Symbol fields.`); + /** descriptor 边界保证校验本身不会执行 Integration getter。 */ + const descriptors = Object.getOwnPropertyDescriptors(value) as Record; + for (const [field, descriptor] of Object.entries(descriptors)) { + if (!allowed.has(field)) + throw new TypeError(`${label} contains unknown field "${field}".`); + if (!('value' in descriptor) || descriptor.enumerable !== true) + throw new TypeError(`${label}.${field} must be an enumerable data property.`); + } + return descriptors as Readonly>; +} + +/** + * 验证 Integration 数组稠密、无自定义字段且不会通过 getter 取值。 + * + * @param value 未受信任数组。 + * @param label 稳定诊断标签。 + * @returns 与原数组容器断开的浅层冻结元素快照。 + */ +export function dataArrayItems(value: unknown, label: string): readonly unknown[] { + if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype + || Object.getOwnPropertySymbols(value).length > 0) { + throw new TypeError(`${label} must be an array without Symbol fields.`); + } + /** length 与每个 index 都从 descriptor 读取,避免稀疏数组和 accessor。 */ + const descriptors = Object.getOwnPropertyDescriptors(value) as Record; + /** 原始 length descriptor 决定精确遍历边界。 */ + const length = descriptors.length?.value; + if (!Number.isSafeInteger(length) || length < 0) + throw new TypeError(`${label} has an invalid length.`); + /** 新数组与调用方容器断开。 */ + const result: unknown[] = []; + for (let index = 0; index < length; index += 1) { + /** 单个 index 必须是显式可枚举 data property。 */ + const descriptor = descriptors[String(index)]; + if (descriptor === undefined || !('value' in descriptor) || descriptor.enumerable !== true) + throw new TypeError(`${label} must be dense and contain only data properties.`); + result.push(descriptor.value); + } + if (Object.keys(descriptors).some(field => field !== 'length' && !/^(?:0|[1-9][0-9]*)$/u.test(field))) + throw new TypeError(`${label} must not contain custom fields.`); + return Object.freeze(result); +} diff --git a/packages/core/src/security/json-snapshot.ts b/packages/core/src/security/json-snapshot.ts new file mode 100644 index 0000000..4b68318 --- /dev/null +++ b/packages/core/src/security/json-snapshot.ts @@ -0,0 +1,157 @@ +import type { JsonValue } from '../contracts/common.js'; +import { compareCodeUnits } from '../serialization/json.js'; + +/** 递归 JSON snapshot 的祖先集合与稳定字段路径。 */ +interface JsonSnapshotState { + readonly ancestors: Set; + readonly path: string; + readonly objectGuard?: (value: object, path: string) => void; +} + +/** + * 返回不执行 getter 的自有字段描述符,并拒绝 Symbol 字段。 + * + * @param value 当前 JSON 容器。 + * @param path 稳定诊断路径。 + * @returns 当前容器的完整字符串字段描述符。 + */ +function ownDescriptors(value: object, path: string): Readonly> { + if (Object.getOwnPropertySymbols(value).length > 0) + throw new TypeError(`${path} must not contain Symbol properties.`); + return Object.getOwnPropertyDescriptors(value); +} + +/** + * 读取一个可枚举 data property,避免 snapshot 执行作者行为。 + * + * @param descriptor 待验证字段描述符。 + * @param path 稳定诊断路径。 + * @returns 字段保存的原始值。 + */ +function dataPropertyValue(descriptor: PropertyDescriptor | undefined, path: string): unknown { + if (descriptor === undefined || !('value' in descriptor) || descriptor.enumerable !== true) + throw new TypeError(`${path} must be an enumerable data property.`); + return descriptor.value; +} + +/** + * 在普通对象上安全定义 JSON 字段,包括不会触发原型 setter 的 `__proto__`。 + * + * @param target snapshot 输出对象。 + * @param key 当前字段名。 + * @param value 已完成递归 snapshot 的字段值。 + */ +function defineJsonField(target: Record, key: string, value: JsonValue): void { + Object.defineProperty(target, key, { + value, + enumerable: true, + configurable: false, + writable: false, + }); +} + +/** + * 递归复制一个严格 JSON 值并冻结全部容器。 + * + * @param value 当前未知输入。 + * @param state 当前祖先集合与诊断路径。 + * @returns 与输入 identity 隔离的 JSON snapshot。 + */ +function snapshotValue(value: unknown, state: JsonSnapshotState): JsonValue { + if (value === null || typeof value === 'string' || typeof value === 'boolean') + return value; + if (typeof value === 'number') { + if (!Number.isFinite(value)) + throw new TypeError(`${state.path} must contain only finite JSON numbers.`); + return value; + } + if (typeof value !== 'object') + throw new TypeError(`${state.path} must contain only JSON values.`); + /** Optional Core-only guard rejects signed capability identities before any field is inspected. */ + state.objectGuard?.(value, state.path); + if (state.ancestors.has(value)) + throw new TypeError(`${state.path} must not contain cycles.`); + + state.ancestors.add(value); + try { + if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype) + throw new TypeError(`${state.path} must be a plain array.`); + /** length 是数组唯一允许的不可枚举自有字段。 */ + const descriptors = ownDescriptors(value, state.path); + /** 数组必须只拥有 length 与范围内的十进制索引。 */ + for (const field of Object.keys(descriptors)) { + if (field === 'length') + continue; + if (!/^(?:0|[1-9][0-9]*)$/u.test(field) || Number(field) >= value.length) + throw new TypeError(`${state.path} arrays must not contain custom properties.`); + } + /** 逐索引读取 descriptor,既拒绝稀疏数组也不执行 getter。 */ + const result: JsonValue[] = []; + for (let index = 0; index < value.length; index += 1) { + /** 缺失 index 使用明确 sparse 诊断,其余 descriptor 仍走 data property 验证。 */ + const descriptor = descriptors[String(index)]; + if (descriptor === undefined) + throw new TypeError(`${state.path} must not contain sparse arrays.`); + result.push(snapshotValue(dataPropertyValue(descriptor, `${state.path}[${index}]`), { + ancestors: state.ancestors, + path: `${state.path}[${index}]`, + ...(state.objectGuard === undefined ? {} : { objectGuard: state.objectGuard }), + })); + } + return Object.freeze(result); + } + + /** JSON object 只接受 object literal 与 null-prototype record。 */ + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) + throw new TypeError(`${state.path} must be a plain object.`); + /** 字段按 code unit 排序并逐个验证为可枚举 data property。 */ + const descriptors = ownDescriptors(value, state.path); + /** 输出使用普通对象;defineProperty 避免特殊键触发 Object.prototype setter。 */ + const result: Record = {}; + for (const field of Object.keys(descriptors).sort(compareCodeUnits)) { + /** 当前字段的稳定诊断路径不读取其值。 */ + const fieldPath = `${state.path}.${field}`; + defineJsonField(result, field, snapshotValue(dataPropertyValue(descriptors[field], fieldPath), { + ancestors: state.ancestors, + path: fieldPath, + ...(state.objectGuard === undefined ? {} : { objectGuard: state.objectGuard }), + })); + } + return Object.freeze(result); + } finally { + state.ancestors.delete(value); + } +} + +/** + * 将未知输入规范化为与调用方 identity 隔离的严格 JSON snapshot。 + * + * 该函数不调用 getter、iterator、toJSON 或其他作者行为,并递归冻结结果。 + * + * @param value 待验证和复制的未知输入。 + * @param label 稳定诊断中的根对象名称。 + * @returns 按对象键稳定排序的深冻结 JSON 值。 + */ +export function snapshotJson(value: unknown, label: string): JsonValue { + if (typeof label !== 'string' || label.length === 0) + throw new TypeError('JSON snapshot label must be a non-empty string.'); + return snapshotValue(value, { ancestors: new Set(), path: label }); +} + +/** + * Core-internal strict JSON snapshot with an object-identity guard. + * + * The guard observes identity only and must not inspect Platform payload fields. It allows the + * Package layer to reject signed Source/Asset capabilities without reserving any JSON shape. + */ +export function snapshotJsonWithObjectGuard( + value: unknown, + label: string, + objectGuard: (value: object, path: string) => void, +): JsonValue { + if (typeof label !== 'string' || label.length === 0) + throw new TypeError('JSON snapshot label must be a non-empty string.'); + return snapshotValue(value, { ancestors: new Set(), path: label, objectGuard }); +} diff --git a/packages/core/src/security/path-policy.ts b/packages/core/src/security/path-policy.ts new file mode 100644 index 0000000..c342f37 --- /dev/null +++ b/packages/core/src/security/path-policy.ts @@ -0,0 +1,156 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +/** 安全边界授权路径的已验证普通文件或目录类型。 */ +export type SafeEntryType = 'file' | 'directory'; + +/** + * 按 Unicode code point 比较文本,不依赖 locale 或 ICU 排序规则。 + * + * @param left 左侧文本。 + * @param right 右侧文本。 + * @returns 与 Array.sort 约定一致的比较结果。 + */ +export function compareCodePoints(left: string, right: string): number { + /** 两侧文本的 Unicode code point 序列。 */ + const leftPoints = [...left].map(character => character.codePointAt(0)!); + /** 右侧文本的 Unicode code point 序列。 */ + const rightPoints = [...right].map(character => character.codePointAt(0)!); + /** 两个序列共同拥有的可比较长度。 */ + const sharedLength = Math.min(leftPoints.length, rightPoints.length); + for (let index = 0; index < sharedLength; index += 1) { + if (leftPoints[index] !== rightPoints[index]) + return leftPoints[index]! < rightPoints[index]! ? -1 : 1; + } + if (leftPoints.length === rightPoints.length) + return 0; + return leftPoints.length < rightPoints.length ? -1 : 1; +} + +/** + * 验证 Integration 提交的 project-relative POSIX 路径。 + * + * @param value 未知路径文本。 + * @param options 是否允许用空字符串表达当前目录。 + * @returns 未经静默折叠或 Unicode 改写的原始安全路径。 + */ +export function safeRelativePath(value: unknown, options: { readonly allowEmpty?: boolean } = {}): string { + if (typeof value !== 'string' || (value.length === 0 && options.allowEmpty !== true)) + throw new Error('Relative path must be a non-empty string.'); + if (value.length === 0) + return value; + if (value.includes('\\')) + throw new Error('Relative path must use POSIX separators.'); + if (value.includes('\0')) + throw new Error('Relative path must not contain NUL bytes.'); + if (path.posix.isAbsolute(value)) + throw new Error('Relative path must not be absolute.'); + /** 原始 segment 逐项拒绝,避免 normalize 静默接受模糊输入。 */ + const segments = value.split('/'); + if (segments.some(segment => segment === '' || segment === '.' || segment === '..')) + throw new Error('Relative path must not contain empty, dot, or parent-directory segments.'); + return value; +} + +/** + * 判断候选路径是否位于指定根内或与根相同。 + * + * @param root 已规范化的绝对根目录。 + * @param candidate 待验证绝对路径。 + * @returns 候选未通过父目录或盘符逃逸时返回 true。 + */ +export function isInsidePath(root: string, candidate: string): boolean { + /** path.relative 能正确区分具有相同文本前缀的兄弟目录。 */ + const relative = path.relative(root, candidate); + return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`)); +} + +/** + * 生成跨大小写和 Unicode NFC 文件系统的路径冲突键。 + * + * @param value 安全 project-relative POSIX 路径。 + * @returns 逐 segment NFC 与小写折叠后的比较键。 + */ +export function sourceCollisionKey(value: string): string { + return value.split('/').map(segment => segment.normalize('NFC').toLowerCase()).join('/'); +} + +/** Author source 树中 exact/case/NFC 路径的内部唯一性索引。 */ +export class SourcePathCollisionRegistry { + /** 折叠后的路径键到首次来源的映射。 */ + readonly #entries = new Map(); + + /** + * 登记一个安全报告路径。 + * + * @param reportPath 工程相对 POSIX 路径。 + * @param identity 对应物理来源的内部唯一身份。 + */ + reserve(reportPath: string, identity: string): void { + /** 大小写与 NFC 折叠后的键用于模拟最严格目标文件系统。 */ + const key = sourceCollisionKey(reportPath); + /** 同一物理来源重复签发合法,两个不同来源折叠到同一键则失败。 */ + const existing = this.#entries.get(key); + if (existing !== undefined && existing.identity !== identity) + throw new Error(`Author source path collision between "${existing.path}" and "${reportPath}".`); + this.#entries.set(key, Object.freeze({ path: reportPath, identity })); + } +} + +/** + * 验证根目录后代的每个路径层级均不是符号链接,并检查最终类型。 + * + * @param physicalRoot 已解析且可信的物理根。 + * @param candidate 根内候选绝对路径。 + * @param type 期望的最终类型。 + * @returns 最终路径的 lstat 结果。 + */ +export async function validatePhysicalEntry( + physicalRoot: string, + candidate: string, + type: SafeEntryType, +): Promise { + if (!path.isAbsolute(physicalRoot) || !path.isAbsolute(candidate) || !isInsidePath(physicalRoot, candidate)) + throw new Error('Authorized source path escapes its physical root.'); + /** 根后每个后代 segment 都必须逐级 lstat。 */ + const segments = path.relative(physicalRoot, candidate).split(path.sep).filter(Boolean); + /** 当前待 lstat 的逐级物理路径。 */ + let current = physicalRoot; + for (const segment of segments) { + current = path.join(current, segment); + /** lstat 不跟随 symlink,确保逃逸在 realpath 前被拒绝。 */ + const stat = await fs.lstat(current); + if (stat.isSymbolicLink()) + throw new Error('Author source trees must not contain symbolic links.'); + if (current !== candidate && !stat.isDirectory()) + throw new Error('Author source path contains a non-directory ancestor.'); + } + /** 根本身或最终后代的准确文件类型。 */ + const finalStat = segments.length === 0 ? await fs.lstat(physicalRoot) : await fs.lstat(candidate); + if (finalStat.isSymbolicLink()) + throw new Error('Author source trees must not contain symbolic links.'); + if ((type === 'file' && !finalStat.isFile()) || (type === 'directory' && !finalStat.isDirectory())) + throw new Error(`Author source must be a regular ${type}.`); + /** realpath 在 lstat 后复核最终解析位置仍位于根内。 */ + /** 宿主临时根的祖先可能自身是系统 symlink,因此比较双方 realpath。 */ + const realRoot = await fs.realpath(physicalRoot); + /** 候选最终解析位置必须仍位于同一真实根内。 */ + const real = await fs.realpath(candidate); + if (!isInsidePath(realRoot, real)) + throw new Error('Author source realpath escapes its physical root.'); + return finalStat; +} + +/** + * 把工程内绝对路径转换为安全、POSIX 且不含绝对前缀的报告路径。 + * + * @param projectRoot 工程绝对根。 + * @param candidate 工程内绝对路径。 + * @returns project-relative POSIX 路径。 + */ +export function projectReportPath(projectRoot: string, candidate: string): string { + if (!isInsidePath(projectRoot, candidate)) + throw new Error('Source path is outside the project root.'); + /** path.relative 输出转换为平台无关的 POSIX 分隔符。 */ + return path.relative(projectRoot, candidate).split(path.sep).join('/'); +} diff --git a/packages/core/src/security/report-safety.ts b/packages/core/src/security/report-safety.ts new file mode 100644 index 0000000..bdd7af7 --- /dev/null +++ b/packages/core/src/security/report-safety.ts @@ -0,0 +1,23 @@ +/** 报告安全边界使用的凭据字段和值保守单行匹配。 */ +const CREDENTIAL = /\b(?:Bearer|Basic)\s+[^\s,;]+|\b(?:token|secret|password|api[_-]?key)\s*[=:]\s*[^\s,;]+/giu; + +/** acplugin 受管临时目录的稳定匹配。 */ +const TEMPORARY_PATH = /\.acplugin-(?:work|stage|backup|transaction|lock)-[^\s/\\]+/giu; + +/** POSIX/Win32 绝对路径匹配,不破坏普通 package-relative path。 */ +const ABSOLUTE_PATH = /(?') + .replace(TEMPORARY_PATH, '') + .replace(ABSOLUTE_PATH, '') + .replace(/[\0\r\n\t]+/gu, ' ') + .trim(); +} diff --git a/packages/core/src/serialization/documents.ts b/packages/core/src/serialization/documents.ts new file mode 100644 index 0000000..bddd632 --- /dev/null +++ b/packages/core/src/serialization/documents.ts @@ -0,0 +1,23 @@ +import { stringify } from 'yaml'; +import { sortObject } from './json.js'; + +/** + * 将值序列化为不受对象插入顺序影响的 YAML。 + * + * @param value 需要序列化的数据。 + * @returns 不带尾随换行、且不主动折叠长行的 YAML 文本。 + */ +export function stableYaml(value: unknown): string { + return stringify(sortObject(value), { lineWidth: 0 }).trimEnd(); +} + +/** + * 组合 YAML frontmatter 与 Markdown 正文,建立统一的空白和结尾换行约定。 + * + * @param frontmatter 文档头部的结构化元数据。 + * @param body Markdown 正文。 + * @returns 可直接写入 Asset 的完整 Markdown 文本。 + */ +export function markdownWithFrontmatter(frontmatter: Record, body: string): string { + return `---\n${stableYaml(frontmatter)}\n---\n${body.trim()}\n`; +} diff --git a/packages/core/src/serialization/index.ts b/packages/core/src/serialization/index.ts new file mode 100644 index 0000000..2a92415 --- /dev/null +++ b/packages/core/src/serialization/index.ts @@ -0,0 +1,2 @@ +export * from './documents.js'; +export * from './json.js'; diff --git a/packages/core/src/serialization/json.ts b/packages/core/src/serialization/json.ts new file mode 100644 index 0000000..c6312e7 --- /dev/null +++ b/packages/core/src/serialization/json.ts @@ -0,0 +1,42 @@ +/** + * 按 ECMAScript UTF-16 code unit 比较字符串,不依赖宿主 locale 或 ICU 数据。 + * + * @param left 左侧字符串。 + * @param right 右侧字符串。 + * @returns 与 Array.sort 约定一致的 -1、0 或 1。 + */ +export function compareCodeUnits(left: string, right: string): number { + if (left === right) + return 0; + return left < right ? -1 : 1; +} + +/** + * 递归复制可序列化值,并按键名排序对象、移除值为 undefined 的字段。 + * + * 数组顺序属于业务语义,因此只处理数组元素而不会重新排序。 + * + * @param value 需要进入 JSON、YAML 或 frontmatter 的数据。 + * @returns 具有确定对象键顺序的等价值。 + */ +export function sortObject(value: unknown): unknown { + if (Array.isArray(value)) + return value.map(sortObject); + if (value !== null && typeof value === 'object') { + return Object.fromEntries(Object.entries(value as Record) + .filter(([, child]) => child !== undefined) + .sort(([a], [b]) => compareCodeUnits(a, b)) + .map(([key, child]) => [key, sortObject(child)])); + } + return value; +} + +/** + * 将值序列化为适合写入 Asset 的确定性格式化 JSON。 + * + * @param value 需要序列化的数据。 + * @returns 使用两个空格缩进且以换行结尾的 JSON 文本。 + */ +export function stableJson(value: unknown): string { + return `${JSON.stringify(sortObject(value), null, 2)}\n`; +} diff --git a/packages/core/src/services/assets.ts b/packages/core/src/services/assets.ts new file mode 100644 index 0000000..e0d6864 --- /dev/null +++ b/packages/core/src/services/assets.ts @@ -0,0 +1,717 @@ +import { createHash } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import type { + AssetMode, + AssetRef, + AssetService, + BytesAssetRef, + GeneratedAssetRef, + GeneratedBytesOriginInput, + SourceAssetRef, + SourceFileRef, +} from '../contracts/services.js'; +import type { ContributedPackageComponent, PackageComponentOrigin } from '../contracts/integrations.js'; +import type { FinalizationAssetService } from '../contracts/packages.js'; +import type { AssetContributor, AssetOrigin } from '../contracts/reports.js'; +import type { CompileAssetOriginInput } from '../contracts/compiler.js'; +import { dataArrayItems, dataObjectFields } from '../security/data-boundary.js'; +import { BuildSessionScope } from './session-scope.js'; +import { compareCodePoints, safeRelativePath, validatePhysicalEntry } from '../security/path-policy.js'; +import { SourceRegistry } from './sources.js'; +import type { WorkDirectoryHandle } from './work-directories.js'; +import { WorkDirectoryRegistry } from './work-directories.js'; + +/** Asset Registry 公开给后续 Package/Report 层的冻结元数据。 */ +export interface AssetRecord { + readonly id: string; + readonly kind: AssetRef['kind']; + readonly owner: string; + readonly mode: AssetMode; + readonly size: number; + readonly sha256: string; + readonly origin: AssetOrigin; +} + +/** Distribution callback 期间新签发 Asset 的一次性授权范围。 */ +export interface AssetIssuanceScope { + readonly service: AssetService; + /** @returns 当前 ref 是否由本 scope 新签发。 */ + readonly includes: (asset: AssetRef) => boolean; + /** 关闭后拒绝 callback 泄漏的 service 继续签发或读取。 */ + readonly close: () => void; +} + +/** Finalization callback 内受限 Component provenance Asset scope。 */ +export interface ComponentFinalizationAssetScope { + readonly service: FinalizationAssetService; + /** 关闭后拒绝泄漏的 finalization AssetService 继续签发或读取。 */ + readonly close: () => void; +} + +/** Asset 物化前仍需保留的私有来源。 */ +type AssetSource = { + readonly type: 'bytes'; + readonly bytes: Uint8Array; +} | { + readonly type: 'file'; + readonly file: string; + readonly root: string; +}; + +/** AssetRef 对应的完整私有授权记录。 */ +interface InternalAssetRecord extends AssetRecord { + readonly session: object; + readonly source: AssetSource; +} + +/** Asset 读取操作的默认单次字节上限。 */ +const DEFAULT_ASSET_READ_LIMIT = 16 * 1024 * 1024; + +/** Asset 读取操作允许的 Core 固定最大上限。 */ +const MAX_ASSET_READ_LIMIT = 64 * 1024 * 1024; + +/** 稳定 operation、job、output 与 subject 共用的 ID 规则。 */ +const STABLE_ORIGIN_ID = /^[a-z0-9]+(?:[-.:/][a-z0-9]+)*$/; + +/** + * 计算不可变 Asset 字节摘要。 + * + * @param bytes 输入字节。 + * @returns SHA-256 十六进制摘要。 + */ +function hashBytes(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +/** + * 读取并哈希一个已验证普通文件。 + * + * @param file 文件绝对路径。 + * @returns 文件字节、长度和摘要。 + */ +async function readAndHashFile(file: string): Promise<{ readonly bytes: Uint8Array; readonly size: number; readonly sha256: string }> { + /** readFile 结果复制为精确 Uint8Array snapshot。 */ + const bytes = Uint8Array.from(await fs.readFile(file)); + return Object.freeze({ bytes, size: bytes.byteLength, sha256: hashBytes(bytes) }); +} + +/** + * 验证 Asset 文件权限。 + * + * @param value 调用方可选 mode。 + * @param fallback 未提供时使用的模式。 + * @returns 0644 或 0755。 + */ +function assetMode(value: AssetMode | undefined, fallback: AssetMode): AssetMode { + /** 未显式提供 mode 时使用来源或调用阶段决定的安全默认。 */ + const mode = value ?? fallback; + if (mode !== 0o644 && mode !== 0o755) + throw new Error('Asset mode must be 0644 or 0755.'); + return mode; +} + +/** + * 验证 Asset 读取上限。 + * + * @param value 调用方请求值。 + * @returns Core 固定范围内的正整数。 + */ +function assetReadLimit(value: number | undefined): number { + /** 省略时使用固定默认,显式值仍不得扩大 Core 上限。 */ + const limit = value ?? DEFAULT_ASSET_READ_LIMIT; + if (!Number.isSafeInteger(limit) || limit <= 0 || limit > MAX_ASSET_READ_LIMIT) + throw new Error(`Asset read limit must be an integer between 1 and ${MAX_ASSET_READ_LIMIT}.`); + return limit; +} + +/** + * 验证稳定来源文本不会承载路径、凭据或任意日志。 + * + * @param value 待验证文本。 + * @param label 诊断字段名称。 + * @returns 原始稳定文本。 + */ +function stableOriginId(value: unknown, label: string): string { + if (typeof value !== 'string' || !STABLE_ORIGIN_ID.test(value)) + throw new Error(`${label} must be a stable lowercase identifier.`); + return value; +} + +/** + * 从当前 Platform finalization 收到的 Component origins 建立稳定 provenance。 + * + * allowed 使用 object identity 而不是 metadata 文本,因此普通对象、其他 Package、 + * 其他 Session 或已过期 scope 的同形 origin 都不能被伪造为贡献来源。 + */ +function componentContributors( + value: unknown, + allowed: ReadonlySet | undefined, +): readonly AssetContributor[] | undefined { + if (value === undefined) + return undefined; + if (allowed === undefined) + throw new Error('Generated Asset componentOrigins are only available during Platform finalization.'); + const inputs = dataArrayItems(value, 'Generated Asset componentOrigins'); + const contributors = inputs.map((origin, index) => { + if (typeof origin !== 'object' || origin === null || !allowed.has(origin as PackageComponentOrigin)) + throw new Error(`Generated Asset componentOrigins[${index}] is not authorized for this Platform finalization.`); + const owner = (origin as PackageComponentOrigin).owner; + const subject = (origin as PackageComponentOrigin).subject; + if (typeof owner !== 'string' || typeof subject !== 'string') + throw new Error(`Generated Asset componentOrigins[${index}] is invalid.`); + return Object.freeze({ owner, subject }); + }); + /** 多条 payload 可来自同一 Extension subject;报告只保留一条稳定审计记录。 */ + const unique = new Map(); + for (const contributor of contributors) + unique.set(`${contributor.owner}\0${contributor.subject}`, contributor); + return Object.freeze([...unique.values()].sort((left, right) => compareCodePoints(left.owner, right.owner) + || compareCodePoints(left.subject, right.subject))); +} + +/** + * Platform-rendered Asset 的逻辑 subjects 只能引用其实际声明的 Component origins。 + * + * Core 自己编码 contribution-driven Document 时仍使用 document: 作为逻辑 subject, + * 因此该约束只由 finalization callback 的 scoped AssetService 启用。 + */ +function validateComponentSubjects( + subjects: readonly string[] | undefined, + contributors: readonly AssetContributor[] | undefined, +): void { + if (subjects === undefined || contributors === undefined) + return; + const available = new Set(contributors.map(contributor => contributor.subject)); + for (const subject of subjects) { + if (!available.has(subject)) + throw new Error(`Generated Asset subject "${subject}" is not declared by its Component origins.`); + } +} + +/** + * 验证 Compiler module report 使用的安全逻辑来源引用。 + * + * @param value project-relative、virtual 或 package identity。 + * @returns 不包含物理绝对路径的原始引用。 + */ +function safeOriginReference(value: unknown): string { + if (typeof value !== 'string' || value.length === 0 || value.includes('\0') || value.includes('\\')) + throw new Error('Compile Asset input must be a safe logical source reference.'); + if (value.startsWith('package:')) { + if (!/^package:(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+@[0-9A-Za-z.+-]+(?:\/[A-Za-z0-9._/-]+)?$/.test(value) + || value.split('/').includes('..')) { + throw new Error('Compile Asset package input is invalid.'); + } + return value; + } + if (value.startsWith('virtual:')) { + if (!/^virtual:[a-z0-9]+(?:[-.:/][a-z0-9]+)*$/.test(value)) + throw new Error('Compile Asset virtual input is invalid.'); + return value; + } + safeRelativePath(value); + return value; +} + +/** 与单次 BuildSession 绑定的 Source、Bytes 与 Generated Asset Registry。 */ +export class AssetRegistry { + /** 当前 BuildSession 的共享存活与身份边界。 */ + readonly #scope: BuildSessionScope; + /** 用于校验 SourceFileRef 原始授权的 Source Registry。 */ + readonly #sources: SourceRegistry; + /** 用于校验 Generated file workDir 授权的 Registry。 */ + readonly #workDirectories: WorkDirectoryRegistry; + /** AssetRef 的对象身份授权记录。 */ + readonly #records = new WeakMap(); + /** owner 闭包之外显式授予的 read/inherit 权限。 */ + readonly #grants = new WeakMap>(); + /** owner 内单调递增且不受其他 owner 并行完成顺序影响的 ref 序号。 */ + readonly #ownerSequences = new Map(); + /** Core 签发的 Component provenance identity 及其当前 Platform/Session 绑定。 */ + readonly #componentOrigins = new WeakMap>(); + + /** + * @returns value 是否为当前 Session 真实签发的 Source/Asset capability identity。 + * + * Package Component JSON 只调用本 identity predicate,不读取 payload 业务字段。 + */ + isCapabilityReference(value: object): boolean { + return this.#records.has(value) || this.#sources.isReference(value); + } + + /** + * 创建当前 BuildSession 唯一 Asset Registry。 + * + * @param scope 当前 BuildSession capability scope。 + * @param sources 当前 Session Source Registry。 + * @param workDirectories 当前 Session owner workDir Registry。 + */ + constructor(scope: BuildSessionScope, sources: SourceRegistry, workDirectories: WorkDirectoryRegistry) { + this.#scope = scope; + this.#sources = sources; + this.#workDirectories = workDirectories; + } + + /** + * 为 owner 创建闭包绑定的 SDK AssetService。 + * + * @param owner 当前 Platform、Extension 或 Framework Resource owner。 + * @returns 不允许调用方自报 owner 的服务。 + */ + service(owner: string): AssetService { + /** 显式接口注解为对象方法提供 SDK 参数的上下文类型。 */ + const service: AssetService = { + /** 从当前 owner 的 SourceFileRef 创建来源 Asset。 */ + fromSource: (source, options) => this.issueSource(owner, source, options), + /** 从复制后的内存字节创建生成 Asset。 */ + fromBytes: input => this.issueBytes(owner, input), + /** 按 owner 或显式 grant 读取 Asset snapshot。 */ + read: (asset, options) => this.read(owner, asset, options), + }; + return Object.freeze(service); + } + + /** + * 由 Package merge 为当前 Platform 签发 Component provenance identity。 + * + * 该 Registry 是唯一能够把 identity 放入私有 WeakMap 的位置;公开 metadata + * 相同的普通对象不能在 later finalization 获得授权。 + */ + issueComponentOrigin(platform: string, owner: string, subject: string): PackageComponentOrigin { + this.#scope.assertActive(); + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(platform) + || typeof owner !== 'string' || typeof subject !== 'string') { + throw new Error('Component origin identity is invalid.'); + } + const origin = Object.freeze({ owner, subject }) as PackageComponentOrigin; + this.#componentOrigins.set(origin, Object.freeze({ platform, owner, subject, session: this.#scope.token })); + return origin; + } + + /** @returns 当前 Platform/Session 真实合并 Component 的签名 origin 集合。 */ + #authorizedComponentOrigins( + platform: string, + components: readonly Pick[], + ): ReadonlySet { + this.#scope.assertActive(); + const origins = new Set(); + for (const component of components) { + if (typeof component !== 'object' || component === null || typeof component.origin !== 'object' || component.origin === null) + throw new Error('Component finalization requires Core-signed Component origins.'); + const record = this.#componentOrigins.get(component.origin); + if (record === undefined || record.session !== this.#scope.token || record.platform !== platform + || record.owner !== component.origin.owner || record.subject !== component.origin.subject) { + throw new Error('Component origin is not authorized for this Platform finalization.'); + } + origins.add(component.origin); + } + return origins; + } + + /** + * 为 Platform finalization 创建带当前 merged Component identity 集合的可撤销服务。 + * + * 只有该 callback service 的 Bytes provenance 可以引用 components;其他阶段仍使用 + * 普通 AssetService,因而不能延展 contribution provenance 的授权边界。 + */ + componentFinalizationScope( + platform: string, + owner: string, + components: readonly Pick[], + ): ComponentFinalizationAssetScope { + this.#scope.assertActive(); + if (typeof owner !== 'string' || owner.length === 0) + throw new Error('Component finalization owner must be a non-empty string.'); + const origins = this.#authorizedComponentOrigins(platform, components); + let active = true; + const assertActive = (): void => { + if (!active) + throw new Error('Component finalization Asset scope is no longer active.'); + this.#scope.assertActive(); + }; + const service: FinalizationAssetService = { + fromSource: async (source, options) => { + assertActive(); + return this.issueSource(owner, source, options); + }, + fromBytes: async (input) => { + assertActive(); + return this.issueBytes(owner, input, origins, true); + }, + read: async (asset, options) => { + assertActive(); + return this.read(owner, asset, options); + }, + }; + const close = (): void => { + active = false; + }; + return Object.freeze({ service: Object.freeze(service), close }); + } + + /** + * 由 Core 在 Platform callback 结束后编码 contribution-driven Document 时使用。 + * + * 它复用相同的签名集合,但不是 AssetService,因此 Platform/Extension 无法在 + * callback 外继续签发带 Component provenance 的 Assets。 + */ + issueFinalizationBytes( + platform: string, + owner: string, + components: readonly Pick[], + input: { + readonly bytes: Uint8Array | string; + readonly mode?: AssetMode; + readonly origin: GeneratedBytesOriginInput; + }, + ): Promise { + return this.issueBytes(owner, input, this.#authorizedComponentOrigins(platform, components)); + } + + /** + * 为单次 Distribution callback 创建可撤销的 Asset Service。 + * + * @param owner 当前 Platform owner。 + * @returns 记录本次新签发 ref 且 callback 后可关闭的 scope。 + */ + issuanceScope(owner: string): AssetIssuanceScope { + this.#scope.assertActive(); + if (typeof owner !== 'string' || owner.length === 0) + throw new Error('Asset issuance owner must be a non-empty string.'); + /** issued 只记录通过本 scope 返回给 callback 的新 ref identity。 */ + const issued = new WeakSet(); + /** active 关闭 callback 后撤销泄漏 service 的全部方法。 */ + let active = true; + /** 每个方法入口统一复核 scope 仍处于授权期。 */ + const assertActive = (): void => { + if (!active) + throw new Error('Asset issuance scope is no longer active.'); + this.#scope.assertActive(); + }; + /** 新签发 ref 在返回 Integration 前登记到当前 scope。 */ + const remember = (asset: T): T => { + issued.add(asset); + return asset; + }; + /** service 保持公开 AssetService 形态但带可撤销 closure。 */ + const service: AssetService = { + /** scope 内 SourceAsset 签发后记录 identity。 */ + fromSource: async (source, options) => { + assertActive(); + return remember(await this.issueSource(owner, source, options)); + }, + /** scope 内 BytesAsset 签发后记录 identity。 */ + fromBytes: async (input) => { + assertActive(); + return remember(await this.issueBytes(owner, input)); + }, + /** callback 读取也受 scope 生命周期约束。 */ + read: async (asset, options) => { + assertActive(); + return this.read(owner, asset, options); + }, + }; + return Object.freeze({ + service: Object.freeze(service), + /** includes 只能查询对象 identity,不暴露签发集合。 */ + includes: (asset: AssetRef) => issued.has(asset), + /** close 幂等撤销 callback 能力。 */ + close: () => { active = false; }, + }); + } + + /** + * 生成本 Session 内唯一但不包含路径语义的 Asset ID。 + * + * @param owner Asset owner。 + * @param kind Asset 来源类别。 + * @returns 只用于安全逻辑引用的稳定形状 ID。 + */ + #nextId(owner: string, kind: AssetRef['kind']): string { + /** owner-local sequence 防止并发 Integration 通过自身 ref ID 观察彼此调度。 */ + const sequence = (this.#ownerSequences.get(owner) ?? 0) + 1; + this.#ownerSequences.set(owner, sequence); + /** hash 避免把任意 owner 文本直接暴露为 ref ID。 */ + const ownerHash = createHash('sha256').update(owner).digest('hex').slice(0, 12); + return `${kind}:${ownerHash}:${sequence}`; + } + + /** + * 为内部记录签发对应的冻结 AssetRef。 + * + * @param input 不含 Session 与 ref ID 的记录输入。 + * @returns 对象身份进入 WeakMap 的 SDK ref。 + */ + #issue(input: Omit): AssetRef { + this.#scope.assertActive(); + /** 每个 ref identity 都使用当前 Session 内部唯一 ID。 */ + const id = this.#nextId(input.owner, input.kind); + /** 公开对象只包含安全 kind/id;绝对路径与 bytes 留在 WeakMap。 */ + const reference = Object.freeze({ kind: input.kind, id }) as AssetRef; + /** origin 与外层元数据均已冻结,私有 source 不通过公开对象可达。 */ + const record = Object.freeze({ ...input, id, session: this.#scope.token }); + this.#records.set(reference, record); + return reference; + } + + /** + * 从当前 owner 的 SourceFileRef 创建 SourceAssetRef。 + * + * @param owner 当前 Context owner。 + * @param source 当前 owner 的精确来源文件。 + * @param options 可选目标 mode。 + * @returns 保留来源 origin 的 AssetRef。 + */ + async issueSource( + owner: string, + source: SourceFileRef, + options: { readonly mode?: AssetMode } = {}, + ): Promise { + this.#scope.assertActive(); + /** SourceRegistry WeakMap authorization 同时阻止伪造和跨 owner ref。 */ + const sourceRecord = this.#sources.authorizeFile(owner, source); + /** 签发前重新验证来源文件类型与 realpath。 */ + const stat = await validatePhysicalEntry(sourceRecord.root, sourceRecord.physicalPath, 'file'); + /** 签发时记录精确字节摘要用于 TOCTOU 检查。 */ + const content = await readAndHashFile(sourceRecord.physicalPath); + if (content.size !== sourceRecord.size || content.sha256 !== sourceRecord.sha256) + throw new Error(`Source file changed after its reference was issued: "${sourceRecord.reportPath}".`); + /** Source origin 只包含安全 Resource owner 和工程相对路径。 */ + const origin = Object.freeze({ type: 'source' as const, resource: owner, path: sourceRecord.reportPath }); + return this.#issue({ + kind: 'source-asset', + owner, + mode: assetMode(options.mode, stat.mode & 0o111 ? 0o755 : 0o644), + size: content.size, + sha256: content.sha256, + origin, + source: Object.freeze({ type: 'file', file: sourceRecord.physicalPath, root: sourceRecord.root }), + }) as SourceAssetRef; + } + + /** + * 从复制后的内存字节创建 BytesAssetRef。 + * + * @param owner 当前 Context owner。 + * @param input 字节、mode 与结构化来源。 + * @returns 不受调用方后续 mutation 影响的 AssetRef。 + */ + async issueBytes(owner: string, input: { + readonly bytes: Uint8Array | string; + readonly mode?: AssetMode; + readonly origin: GeneratedBytesOriginInput; + }, allowedComponentOrigins?: ReadonlySet, enforceComponentSubjects = false): Promise { + this.#scope.assertActive(); + const inputFields = dataObjectFields(input, new Set(['bytes', 'mode', 'origin']), 'Bytes Asset input'); + const originFields = dataObjectFields( + inputFields.origin?.value, + new Set(['operation', 'subjects', 'componentOrigins']), + 'Generated Asset origin', + ); + /** 字符串按 UTF-8 编码,Uint8Array 必须复制底层存储。 */ + const bytesInput = inputFields.bytes?.value; + if (typeof bytesInput !== 'string' && !(bytesInput instanceof Uint8Array)) + throw new Error('Bytes Asset bytes must be a string or Uint8Array.'); + const bytes = typeof bytesInput === 'string' ? new TextEncoder().encode(bytesInput) : Uint8Array.from(bytesInput); + /** subjects 是稳定标识集合,复制、去重并按 code point 排序。 */ + const subjectInputs = originFields.subjects === undefined + ? undefined + : dataArrayItems(originFields.subjects.value, 'Generated Asset subjects'); + const subjects = subjectInputs?.map(subject => stableOriginId(subject, 'Generated Asset subject')).sort(compareCodePoints); + if (subjects !== undefined && new Set(subjects).size !== subjects.length) + throw new Error('Generated Asset subjects must not contain duplicates.'); + const contributors = componentContributors( + originFields.componentOrigins?.value, + allowedComponentOrigins, + ); + if (enforceComponentSubjects) + validateComponentSubjects(subjects, contributors); + /** owner 由闭包覆盖,调用方只能填写 operation/subjects。 */ + const origin = Object.freeze({ + type: 'generated' as const, + owner, + operation: stableOriginId(originFields.operation?.value, 'Generated Asset operation'), + ...(subjects === undefined ? {} : { subjects: Object.freeze(subjects) }), + ...(contributors === undefined ? {} : { contributors }), + }); + return this.#issue({ + kind: 'bytes-asset', + owner, + mode: assetMode(inputFields.mode?.value as AssetMode | undefined, 0o644), + size: bytes.byteLength, + sha256: hashBytes(bytes), + origin, + source: Object.freeze({ type: 'bytes', bytes }), + }) as BytesAssetRef; + } + + /** + * 由 Compiler Host 从 owner workDir 普通文件签发 GeneratedAssetRef。 + * + * @param owner Compiler Context 固定 owner。 + * @param workDirectory 当前 owner 的 workDir 句柄。 + * @param relativeFile workDir-relative 输出文件。 + * @param mode 输出权限。 + * @param origin 编译 job/output/input 来源。 + * @returns 带 TOCTOU 文件来源的生成 Asset。 + */ + async issueGenerated( + owner: string, + workDirectory: WorkDirectoryHandle, + relativeFile: string, + mode: AssetMode, + origin: CompileAssetOriginInput, + ): Promise { + this.#scope.assertActive(); + /** WorkDirectoryRegistry 验证对象 identity、owner、Session、symlink 与普通文件类型。 */ + const generated = await this.#workDirectories.generatedFile(owner, workDirectory, relativeFile); + /** 签发时读取精确生成文件快照用于摘要和 TOCTOU 基线。 */ + const content = await readAndHashFile(generated.file); + /** 编译来源 inputs 使用安全 path/package/virtual identity 且稳定排序。 */ + const inputs = origin.inputs.map(safeOriginReference).sort(compareCodePoints); + if (new Set(inputs).size !== inputs.length) + throw new Error('Compile Asset inputs must not contain duplicates.'); + /** profile/kind 是 Execution/Report 依赖的结构化 compile provenance。 */ + if (origin.profile !== 'portable-node' && origin.profile !== 'managed-rolldown') + throw new Error('Compile Asset profile is invalid.'); + if (origin.kind !== 'chunk' && origin.kind !== 'asset' && origin.kind !== 'licenses') + throw new Error('Compile Asset kind is invalid.'); + /** owner 同样由 Host 闭包覆盖。 */ + const assetOrigin = Object.freeze({ + type: 'compile' as const, + owner, + job: stableOriginId(origin.job, 'Compile job'), + output: stableOriginId(origin.output, 'Compile output'), + profile: origin.profile, + kind: origin.kind, + inputs: Object.freeze(inputs), + }); + return this.#issue({ + kind: 'generated-asset', + owner, + mode: assetMode(mode, 0o644), + size: content.size, + sha256: content.sha256, + origin: assetOrigin, + source: Object.freeze({ type: 'file', file: generated.file, root: generated.root }), + }) as GeneratedAssetRef; + } + + /** + * 显式授予另一个 owner 读取或继承一个既有 AssetRef。 + * + * @param granter 当前 ref owner。 + * @param grantee 获得权限的 Platform 或 Framework owner。 + * @param asset 当前 Session 原始 ref 对象。 + */ + grant(granter: string, grantee: string, asset: AssetRef): void { + this.#scope.assertActive(); + if (typeof grantee !== 'string' || grantee.length === 0) + throw new Error('Asset grantee must be a non-empty owner.'); + if (typeof asset !== 'object' || asset === null) + throw new Error('Asset reference is not authorized for this BuildSession.'); + /** grant 只接受原始 ref owner,已有 grantee 不能继续转授权。 */ + const record = this.#records.get(asset); + if (record === undefined || record.session !== this.#scope.token || record.owner !== granter) + throw new Error('Only the Asset owner can grant access.'); + /** grant 与原 ref 对象 identity 绑定,复制等形对象无法继承。 */ + const grants = this.#grants.get(asset) ?? new Set(); + grants.add(grantee); + this.#grants.set(asset, grants); + } + + /** + * 授权并返回当前 Session 的内部 Asset 记录。 + * + * @param owner 当前 Context owner。 + * @param asset 待使用 AssetRef。 + * @returns 当前 Registry 内部记录。 + */ + #authorize(owner: string, asset: unknown): InternalAssetRecord { + this.#scope.assertActive(); + if (typeof asset !== 'object' || asset === null) + throw new Error('Asset reference is not authorized for this BuildSession.'); + /** WeakMap 对象身份是运行时授权唯一依据。 */ + const record = this.#records.get(asset); + if (record === undefined || record.session !== this.#scope.token + || (record.owner !== owner && !this.#grants.get(asset)?.has(owner))) { + throw new Error('Asset reference is not authorized for this owner and BuildSession.'); + } + return record; + } + + /** + * 读取一个 owner 可访问的 Asset snapshot。 + * + * @param owner 当前 Context owner。 + * @param asset 当前 Session 原始 AssetRef。 + * @param options 可选读取上限。 + * @returns 复制且不共享内部存储的字节。 + */ + async read(owner: string, asset: AssetRef, options: { readonly maxBytes?: number } = {}): Promise { + /** 当前 owner 必须是 issuer 或显式 grantee。 */ + const record = this.#authorize(owner, asset); + /** 单次读取始终受 Core 最大值约束。 */ + const limit = assetReadLimit(options.maxBytes); + if (record.size > limit) + throw new Error(`Asset "${record.id}" exceeds the requested read limit.`); + if (record.source.type === 'bytes') + return Uint8Array.from(record.source.bytes); + /** 文件型 Asset 每次读取前执行 TOCTOU preflight。 */ + const bytes = await this.#verifiedFileBytes(record); + return Uint8Array.from(bytes); + } + + /** + * 在 candidate/stage 物化前复核 Asset 并返回字节。 + * + * @param owner 当前被授权物化的 owner。 + * @param asset 当前 Session 原始 AssetRef。 + * @returns 与签发摘要一致的精确字节。 + */ + async materializationBytes(owner: string, asset: AssetRef): Promise { + /** Materializer 也必须持有当前 Session 的明确 grant。 */ + const record = this.#authorize(owner, asset); + if (record.source.type === 'bytes') + return Uint8Array.from(record.source.bytes); + return this.#verifiedFileBytes(record); + } + + /** + * 复核文件类型、realpath、size 与 hash。 + * + * @param record 文件型 Asset 内部记录。 + * @returns 当前精确字节。 + */ + async #verifiedFileBytes(record: InternalAssetRecord): Promise { + if (record.source.type !== 'file') + throw new Error('Internal Asset source invariant failed.'); + await validatePhysicalEntry(record.source.root, record.source.file, 'file'); + /** 当前文件内容必须仍与签发 snapshot 摘要相同。 */ + const current = await readAndHashFile(record.source.file); + if (current.size !== record.size || current.sha256 !== record.sha256) + throw new Error(`Asset source changed after it was issued: ${record.id}.`); + return current.bytes; + } + + /** + * 返回不包含私有来源的 Asset 元数据。 + * + * @param owner 当前具有访问 grant 的 owner。 + * @param asset 当前 Session 原始 AssetRef。 + * @returns 可进入 Package snapshot/report 的冻结记录。 + */ + describe(owner: string, asset: AssetRef): AssetRecord { + /** 描述操作不能旁路与读取相同的授权边界。 */ + const record = this.#authorize(owner, asset); + return Object.freeze({ + id: record.id, + kind: record.kind, + owner: record.owner, + mode: record.mode, + size: record.size, + sha256: record.sha256, + origin: record.origin, + }); + } +} diff --git a/packages/core/src/services/diagnostics.ts b/packages/core/src/services/diagnostics.ts new file mode 100644 index 0000000..55f3c34 --- /dev/null +++ b/packages/core/src/services/diagnostics.ts @@ -0,0 +1,150 @@ +import type { + Diagnostic, + DiagnosticPhase, +} from '../contracts/reports.js'; +import type { + DiagnosticInput, + DiagnosticService, +} from '../contracts/services.js'; +import { compareCodePoints, safeRelativePath } from '../security/path-policy.js'; +import { sanitizeStableText } from '../security/report-safety.js'; + +/** + * 比较可选稳定文本。 + * + * @param left 左侧值。 + * @param right 右侧值。 + * @returns code-point 排序结果。 + */ +function compareOptional(left: string | undefined, right: string | undefined): number { + return compareCodePoints(left ?? '', right ?? ''); +} + +/** + * 校验并复制 Integration 可提交的诊断。 + * + * @param input 未受信任的诊断输入。 + * @returns 不含绝对路径和未知字段的冻结快照。 + */ +function diagnosticInput(input: DiagnosticInput): DiagnosticInput { + if (typeof input !== 'object' || input === null || Array.isArray(input)) + throw new TypeError('Diagnostic input must be an object.'); + /** SDK 诊断允许出现的完整字段集合。 */ + const allowed = new Set(['code', 'severity', 'message', 'location', 'fieldPath', 'hint']); + if (Object.keys(input).some(field => !allowed.has(field))) + throw new TypeError('Diagnostic input contains unknown fields.'); + if (!/^[A-Z][A-Z0-9_]*$/.test(input.code) + || (input.severity !== 'warning' && input.severity !== 'error') + || typeof input.message !== 'string' || input.message.length === 0) { + throw new TypeError('Diagnostic code, severity or message is invalid.'); + } + /** 自由文本在进入稳定集合前移除凭据、绝对路径和控制字符。 */ + const message = sanitizeStableText(input.message); + if (message.length === 0) + throw new TypeError('Diagnostic message must not become empty after sanitization.'); + /** 可选来源位置只能使用安全工程相对路径。 */ + const location = input.location === undefined + ? undefined + : Object.freeze({ + path: safeRelativePath(input.location.path), + ...(input.location.line === undefined ? {} : { line: input.location.line }), + ...(input.location.column === undefined ? {} : { column: input.location.column }), + }); + if (location !== undefined + && ((location.line !== undefined && (!Number.isSafeInteger(location.line) || location.line <= 0)) + || (location.column !== undefined && (!Number.isSafeInteger(location.column) || location.column <= 0)))) { + throw new TypeError('Diagnostic location coordinates are invalid.'); + } + /** 字段路径复制后不再受调用方 mutation 影响。 */ + const fieldPath = input.fieldPath === undefined ? undefined : Object.freeze([...input.fieldPath]); + if (fieldPath?.some(field => (typeof field !== 'string' && typeof field !== 'number') + || (typeof field === 'number' && (!Number.isSafeInteger(field) || field < 0)))) { + throw new TypeError('Diagnostic fieldPath is invalid.'); + } + if (input.hint !== undefined && (typeof input.hint !== 'string' || input.hint.length === 0)) + throw new TypeError('Diagnostic hint is invalid.'); + /** hint 使用与 message 相同的稳定脱敏边界。 */ + const hint = input.hint === undefined ? undefined : sanitizeStableText(input.hint); + if (hint !== undefined && hint.length === 0) + throw new TypeError('Diagnostic hint must not become empty after sanitization.'); + return Object.freeze({ + code: input.code, + severity: input.severity, + message, + ...(location === undefined ? {} : { location }), + ...(fieldPath === undefined ? {} : { fieldPath }), + ...(hint === undefined ? {} : { hint }), + }); +} + +/** BuildSession 内统一绑定 phase/owner 的诊断 Registry。 */ +export class DiagnosticRegistry { + /** 尚未排序的内部诊断集合。 */ + readonly #items: Diagnostic[] = []; + + /** @returns 当前是否已经存在阻止构建的错误。 */ + get hasErrors(): boolean { + return this.#items.some(item => item.severity === 'error'); + } + + /** @returns 脱离内部数组且稳定排序的冻结诊断快照。 */ + get diagnostics(): readonly Diagnostic[] { + return Object.freeze([...this.#items].sort((left, right) => + compareOptional(left.platform, right.platform) + || compareOptional(left.extension, right.extension) + || compareOptional(left.owner, right.owner) + || compareOptional(left.location?.path, right.location?.path) + || (left.location?.line ?? 0) - (right.location?.line ?? 0) + || compareCodePoints(left.code, right.code) + || compareCodePoints(left.message, right.message))); + } + + /** + * 由 Core 自己提交已经绑定身份的诊断。 + * + * @param phase 固定生命周期阶段。 + * @param input 公开诊断字段。 + * @param identity 可选 owner/platform/extension/component 身份。 + */ + report( + phase: DiagnosticPhase, + input: DiagnosticInput, + identity: Pick = {}, + ): void { + /** 所有公开字段在进入可排序集合前建立数据边界。 */ + const normalized = diagnosticInput(input); + /** related locations 也必须逐项通过 project-relative path 边界。 */ + const related = identity.related?.map((location) => { + /** 重用公开 Diagnostic location 校验以保持坐标规则一致。 */ + const validated = diagnosticInput({ code: 'RELATED_LOCATION', severity: 'error', message: 'Related location.', location }).location!; + return validated; + }); + /** Core identity 字段从调用者闭包传入而不是从 SDK input 读取。 */ + this.#items.push(Object.freeze({ + ...normalized, + phase, + ...(identity.owner === undefined ? {} : { owner: identity.owner }), + ...(identity.platform === undefined ? {} : { platform: identity.platform }), + ...(identity.extension === undefined ? {} : { extension: identity.extension }), + ...(identity.component === undefined ? {} : { component: Object.freeze({ ...identity.component }) }), + ...(related === undefined ? {} : { related: Object.freeze(related) }), + })); + } + + /** + * 创建不允许调用方覆盖 phase/owner 的闭包服务。 + * + * @param phase 当前固定阶段。 + * @param identity 当前 owner/platform/extension 身份。 + * @returns 冻结 DiagnosticService。 + */ + service( + phase: DiagnosticPhase, + identity: Pick = {}, + ): DiagnosticService { + return Object.freeze({ + /** 调用方只能提交公开字段,phase 与身份由闭包固定。 */ + report: (input: DiagnosticInput) => this.report(phase, input, identity), + }); + } +} diff --git a/packages/core/src/services/execution.ts b/packages/core/src/services/execution.ts new file mode 100644 index 0000000..375f3c1 --- /dev/null +++ b/packages/core/src/services/execution.ts @@ -0,0 +1,249 @@ +import { spawn } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import process from 'node:process'; +import type { + ExecutionResult, + ExecutionService, + GeneratedAssetRef, +} from '../contracts/services.js'; +import { AssetRegistry } from './assets.js'; +import { WorkDirectoryRegistry } from './work-directories.js'; + +/** Execution Host 固定全局超时上限。 */ +const MAX_TIMEOUT_MS = 60_000; + +/** Execution Host 固定 stdout+stderr 单流字节上限。 */ +const MAX_OUTPUT_BYTES = 4 * 1024 * 1024; + +/** Execution Host stdin/args/env literal 单值上限。 */ +const MAX_INPUT_BYTES = 1024 * 1024; + +/** 调用方允许显式传入的普通环境变量名。 */ +const ENVIRONMENT_NAME = /^[A-Z_][A-Z0-9_]*$/; + +/** 永远拒绝通过 Execution API 注入的常见凭据变量名片段。 */ +const SENSITIVE_ENVIRONMENT = /(?:TOKEN|SECRET|PASSWORD|PASSWD|PRIVATE|CREDENTIAL|AUTH|COOKIE|SESSION|KEY)/u; + +/** 会改变 Node/动态链接器执行边界的环境变量。 */ +const RUNTIME_CONTROL_ENVIRONMENT = /^(?:(?:NODE|NPM|PNPM|YARN|LD|DYLD)_|PATH$|HOME$|USERPROFILE$|TMPDIR$|TEMP$|TMP$)/u; + +/** + * 校验调用方请求的正整数上限。 + * + * @param value 请求值。 + * @param maximum Core 固定最大值。 + * @param label 稳定诊断字段。 + * @returns 可安全交给 timer/stream 的整数。 + */ +function boundedInteger(value: unknown, maximum: number, label: string): number { + if (!Number.isSafeInteger(value) || (value as number) <= 0 || (value as number) > maximum) + throw new Error(`${label} must be a positive integer no greater than ${maximum}.`); + return value as number; +} + +/** + * 复制并验证一个 protocol-neutral literal 环境。 + * + * @param input 调用方显式环境字段。 + * @returns 不继承 process.env 的最小环境。 + */ +function executionEnvironment(input: unknown): NodeJS.ProcessEnv { + if (input === undefined) + return {}; + if (typeof input !== 'object' || input === null || Array.isArray(input)) + throw new Error('Execution environment must be a string map.'); + /** 只接受 data property,避免读取 getter。 */ + const descriptors = Object.getOwnPropertyDescriptors(input); + /** 不继承宿主环境的全新最小环境。 */ + const environment: NodeJS.ProcessEnv = {}; + for (const name of Object.keys(descriptors).sort()) { + /** 当前环境变量的 data property descriptor。 */ + const descriptor = descriptors[name]!; + if (!('value' in descriptor) || !ENVIRONMENT_NAME.test(name) || SENSITIVE_ENVIRONMENT.test(name) + || RUNTIME_CONTROL_ENVIRONMENT.test(name) + || typeof descriptor.value !== 'string' || Buffer.byteLength(descriptor.value) > 4096) { + throw new Error('Execution environment contains an unsafe name or value.'); + } + environment[name] = descriptor.value; + } + /** Windows Node 启动所需的系统根可以从宿主复制,但不会复制其他环境。 */ + if (process.platform === 'win32' && typeof process.env.SystemRoot === 'string') + environment.SystemRoot = process.env.SystemRoot; + return environment; +} + +/** Execution Host 的 Session registries。 */ +export interface ExecutionHostOptions { + readonly assets: AssetRegistry; + readonly workDirectories: WorkDirectoryRegistry; +} + +/** Core 唯一 process execution boundary。 */ +export class ExecutionHost { + /** AssetRef runtime authorization 和 bytes snapshot。 */ + readonly #assets: AssetRegistry; + /** owner-scoped 隔离 cwd 与 materialization root。 */ + readonly #workDirectories: WorkDirectoryRegistry; + /** owner 内执行序号只用于 workDir 路径,不进入公开结果。 */ + readonly #sequences = new Map(); + + /** + * 创建当前 BuildSession 的 Execution Host。 + * + * @param options 当前 Session registries。 + */ + constructor(options: ExecutionHostOptions) { + this.#assets = options.assets; + this.#workDirectories = options.workDirectories; + } + + /** + * 为一个 owner 签发闭包绑定的 ExecutionService。 + * + * @param owner 当前 Framework/Extension owner。 + * @returns 不接受调用方自报 owner 的执行能力。 + */ + service(owner: string): ExecutionService { + if (typeof owner !== 'string' || owner.length === 0) + throw new Error('Execution owner must be a non-empty string.'); + /** 显式类型注解保留 SDK request 的上下文类型。 */ + const service: ExecutionService = { + /** portable entry 的授权和执行始终绑定当前 owner。 */ + runNode: request => this.#runNode(owner, request), + }; + return Object.freeze(service); + } + + /** + * 物化并运行一个受权 portable-node entry。 + * + * @param owner 当前 service owner。 + * @param request 执行入口和固定资源上限。 + * @returns 不携带 cwd/path/error stack 的稳定进程结果。 + */ + async #runNode(owner: string, request: { + readonly entry: GeneratedAssetRef; + readonly args?: readonly string[]; + readonly stdin?: Uint8Array | string; + readonly timeoutMs: number; + readonly maxOutputBytes: number; + readonly environment?: Readonly>; + }): Promise { + if (typeof request !== 'object' || request === null + || Object.keys(request).some(field => !new Set(['entry', 'args', 'stdin', 'timeoutMs', 'maxOutputBytes', 'environment']).has(field))) { + throw new Error('Execution request contains unknown fields.'); + } + /** 当前请求经 Core 全局上限收窄后的超时。 */ + const timeoutMs = boundedInteger(request.timeoutMs, MAX_TIMEOUT_MS, 'Execution timeoutMs'); + /** 当前请求经 Core 全局上限收窄后的输出限制。 */ + const maxOutputBytes = boundedInteger(request.maxOutputBytes, MAX_OUTPUT_BYTES, 'Execution maxOutputBytes'); + /** entry 必须是当前 owner 可读且确实来自 portable main Chunk。 */ + const metadata = this.#assets.describe(owner, request.entry); + if (metadata.kind !== 'generated-asset' || metadata.origin.type !== 'compile' + || metadata.origin.profile !== 'portable-node' || metadata.origin.kind !== 'chunk') { + throw new Error('Execution entry must be a portable-node generated chunk.'); + } + /** 参数是不会经过 shell 的 literal string 数组。 */ + const args = request.args === undefined ? [] : [...request.args]; + if (args.some(value => typeof value !== 'string' || value.includes('\0') || Buffer.byteLength(value) > 4096) + || args.reduce((size, value) => size + Buffer.byteLength(value), 0) > MAX_INPUT_BYTES) { + throw new Error('Execution args exceed the safe literal boundary.'); + } + /** stdin 在 spawn 前复制,调用方后续 mutation 不影响执行。 */ + const stdin = request.stdin === undefined + ? undefined + : typeof request.stdin === 'string' + ? Buffer.from(request.stdin) + : Buffer.from(Uint8Array.from(request.stdin)); + if (stdin !== undefined && stdin.byteLength > MAX_INPUT_BYTES) + throw new Error('Execution stdin exceeds the safe input boundary.'); + /** 不继承宿主变量的安全最小环境。 */ + const environment = executionEnvironment(request.environment); + /** 每次执行使用 owner workDir 下新的隔离 cwd。 */ + const sequence = (this.#sequences.get(owner) ?? 0) + 1; + this.#sequences.set(owner, sequence); + /** 当前 owner 的唯一 workDir handle。 */ + const workDirectory = await this.#workDirectories.directory(owner); + /** 本次执行独占的 workDir-relative 根。 */ + const relativeRoot = `execution/${sequence}`; + /** 子进程隔离 cwd。 */ + const cwd = this.#workDirectories.resolve(owner, workDirectory, relativeRoot); + /** portable main 的临时物化路径。 */ + const entry = this.#workDirectories.resolve(owner, workDirectory, `${relativeRoot}/main.mjs`); + try { + await fs.mkdir(cwd, { recursive: true, mode: 0o700 }); + /** 读取同时复核生成文件 hash;临时物化保留受管 Asset mode。 */ + await fs.writeFile(entry, await this.#assets.read(owner, request.entry), { flag: 'wx', mode: metadata.mode }); + return await new Promise((resolve, reject) => { + /** 不经过 shell/PATH,只使用当前 Node executable。 */ + const child = spawn(process.execPath, [entry, ...args], { + cwd, + env: environment, + shell: false, + stdio: ['pipe', 'pipe', 'pipe'], + }); + /** 两个输出流分别在同一 Core 上限内收集。 */ + const stdout: Buffer[] = []; + /** stderr 的稳定 chunk snapshots。 */ + const stderr: Buffer[] = []; + /** stdout 与 stderr 共享同一个请求输出预算。 */ + let outputSize = 0; + /** timeout/output-limit 提前决定的稳定状态。 */ + let terminalStatus: ExecutionResult['status'] | undefined; + /** error/close 只能完成 Promise 一次。 */ + let settled = false; + /** 超时触发后强制终止,不返回原始错误。 */ + const terminate = (status: 'timed-out' | 'output-limit'): void => { + if (terminalStatus !== undefined) + return; + terminalStatus = status; + child.kill('SIGKILL'); + }; + /** 当前请求固定超时计时器。 */ + const timeout = setTimeout(() => terminate('timed-out'), timeoutMs); + /** 输出超限时不保存越界 chunk 并终止。 */ + const collect = (target: Buffer[], chunk: Buffer): void => { + /** 两个输出流加入当前 chunk 后的候选总大小。 */ + const next = outputSize + chunk.byteLength; + if (next > maxOutputBytes) { + terminate('output-limit'); + return; + } + target.push(Buffer.from(chunk)); + outputSize = next; + }; + child.stdout.on('data', (chunk: Buffer) => collect(stdout, chunk)); + child.stderr.on('data', (chunk: Buffer) => collect(stderr, chunk)); + /** 子进程提前退出造成的 EPIPE 不得成为未处理的宿主异常。 */ + child.stdin.on('error', () => undefined); + child.once('error', () => { + clearTimeout(timeout); + if (!settled) { + settled = true; + reject(new Error('Node execution could not start.')); + } + }); + child.once('close', (exitCode, signal) => { + clearTimeout(timeout); + if (settled) + return; + settled = true; + resolve(Object.freeze({ + status: terminalStatus ?? (signal === null ? 'exited' : 'signaled'), + exitCode, + signal, + stdout: Uint8Array.from(Buffer.concat(stdout)), + stderr: Uint8Array.from(Buffer.concat(stderr)), + })); + }); + if (stdin === undefined) + child.stdin.end(); + else + child.stdin.end(stdin); + }); + } finally { + /** execution work materialization 在所有成功/失败路径清理。 */ + await fs.rm(cwd, { recursive: true, force: true }); + } + } +} diff --git a/packages/core/src/services/extension-state.ts b/packages/core/src/services/extension-state.ts new file mode 100644 index 0000000..7224e3b --- /dev/null +++ b/packages/core/src/services/extension-state.ts @@ -0,0 +1,161 @@ +import type { + AssetRef, + SourceDirectoryRef, + SourceFileRef, +} from '../contracts/services.js'; +import { AssetRegistry } from './assets.js'; +import { SourceRegistry } from './sources.js'; + +/** Extension State 支持的两个权限阶段。 */ +export type ExtensionStatePhase = 'discovered' | 'validated' | 'built'; + +/** State snapshot 递归上下文。 */ +interface SnapshotContext { + readonly owner: string; + readonly phase: ExtensionStatePhase; + readonly sources: SourceRegistry; + readonly assets: AssetRegistry; + readonly ancestors: Set; + readonly path: string; +} + +/** + * 尝试把对象识别为当前 owner 的受权 SourceRef。 + * + * @param value 当前对象。 + * @param context Extension snapshot 上下文。 + * @returns 有效 SourceRef 原始 identity 或 undefined。 + */ +function sourceReference(value: object, context: SnapshotContext): SourceDirectoryRef | SourceFileRef | undefined { + /** kind 只用于选择 Registry 授权分支,不能作为真实性依据。 */ + const kind = (value as { readonly kind?: unknown }).kind; + if (context.phase === 'built') { + if (kind === 'source-file' || kind === 'source-directory') + throw new TypeError(`${context.path} must not contain SourceRef after build.`); + return undefined; + } + try { + if (kind === 'source-file') { + context.sources.authorizeFile(context.owner, value as SourceFileRef); + return value as SourceFileRef; + } + if (kind === 'source-directory') { + context.sources.authorizeDirectory(context.owner, value as SourceDirectoryRef); + return value as SourceDirectoryRef; + } + } catch { + throw new TypeError(`${context.path} contains a forged or unauthorized SourceRef.`); + } + return undefined; +} + +/** + * 尝试把对象识别为当前 owner 的受权 AssetRef。 + * + * @param value 当前对象。 + * @param context Extension snapshot 上下文。 + * @returns 有效 AssetRef 原始 identity 或 undefined。 + */ +function assetReference(value: object, context: SnapshotContext): AssetRef | undefined { + /** Asset kind 同样必须随后通过 WeakMap identity 校验。 */ + const kind = (value as { readonly kind?: unknown }).kind; + if (kind !== 'source-asset' && kind !== 'generated-asset' && kind !== 'bytes-asset') + return undefined; + try { + context.assets.describe(context.owner, value as AssetRef); + return value as AssetRef; + } catch { + throw new TypeError(`${context.path} contains a forged or unauthorized AssetRef.`); + } +} + +/** + * 复制普通 State 数据并保留不可伪造 ref identity。 + * + * @param value 当前递归值。 + * @param context 当前路径和权限边界。 + * @returns 深冻普通数据或原始受权 ref。 + */ +function snapshotValue(value: unknown, context: SnapshotContext): unknown { + if (value === null || typeof value === 'string' || typeof value === 'boolean') + return value; + if (typeof value === 'number') { + if (!Number.isFinite(value)) + throw new TypeError(`${context.path} contains a non-finite number.`); + return value; + } + if (typeof value !== 'object') + throw new TypeError(`${context.path} contains unsupported executable or symbolic data.`); + /** SourceRef/AssetRef 必须在读取对象字段前按 Registry identity 授权。 */ + const source = sourceReference(value, context); + if (source !== undefined) + return source; + /** AssetRef 保留原始不可伪造对象 identity。 */ + const asset = assetReference(value, context); + if (asset !== undefined) + return asset; + if (context.ancestors.has(value)) + throw new TypeError(`${context.path} contains a cycle.`); + context.ancestors.add(value); + try { + if (Array.isArray(value)) { + /** 稀疏或带自定义属性的数组不是无歧义 State。 */ + const fields = Object.getOwnPropertyDescriptors(value); + for (let index = 0; index < value.length; index += 1) { + if (!Object.prototype.hasOwnProperty.call(value, index)) + throw new TypeError(`${context.path} contains a sparse array.`); + } + if (Object.keys(fields).some(field => field !== 'length' && !/^(?:0|[1-9][0-9]*)$/u.test(field))) + throw new TypeError(`${context.path} arrays must not contain custom fields.`); + return Object.freeze(value.map((item, index) => snapshotValue(item, { ...context, path: `${context.path}[${index}]` }))); + } + /** class/Date/Map/Set 和自定义 prototype 全部拒绝。 */ + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) + throw new TypeError(`${context.path} must contain only plain objects.`); + if (Object.getOwnPropertySymbols(value).length > 0) + throw new TypeError(`${context.path} must not contain Symbol fields.`); + /** data descriptor 检查避免执行 getter。 */ + const fields = Object.getOwnPropertyDescriptors(value); + /** snapshot 容器重新创建以断开调用方后续 mutation。 */ + const result: Record = {}; + for (const field of Object.keys(fields).sort()) { + /** 每个字段只读取已确认的数据描述符。 */ + const descriptor = fields[field]!; + if (!('value' in descriptor)) + throw new TypeError(`${context.path}.${field} must be a data property.`); + Object.defineProperty(result, field, { + value: snapshotValue(descriptor.value, { ...context, path: `${context.path}.${field}` }), + enumerable: true, + configurable: false, + writable: false, + }); + } + return Object.freeze(result); + } finally { + context.ancestors.delete(value); + } +} + +/** + * 建立 discovered/validated/Built State 的唯一数据边界。 + * + * @param value Extension 返回的未知值。 + * @param options 当前 Extension owner、阶段和 Registry。 + * @returns 与普通调用方容器断开、ref identity 保留的不可变 State。 + */ +export function snapshotExtensionState( + value: T, + options: { + readonly owner: string; + readonly phase: ExtensionStatePhase; + readonly sources: SourceRegistry; + readonly assets: AssetRegistry; + }, +): Readonly { + return snapshotValue(value, { + ...options, + ancestors: new Set(), + path: `Extension ${options.phase} state`, + }) as Readonly; +} diff --git a/packages/core/src/services/session-scope.ts b/packages/core/src/services/session-scope.ts new file mode 100644 index 0000000..cbc9b23 --- /dev/null +++ b/packages/core/src/services/session-scope.ts @@ -0,0 +1,28 @@ +/** + * 为一次 BuildSession 的 capability registry 绑定共同存活状态。 + * + * Scope 本身不通过 SDK 暴露;SourceRef/AssetRef 的运行时授权仍由各 Registry + * 的 WeakMap 对象身份记录完成。 + */ +export class BuildSessionScope { + /** 当前 Session 是否仍允许使用已签发能力。 */ + #active = true; + + /** 当前 Scope 独占且不可从公开 ref 恢复的身份 token。 */ + readonly token: Readonly> = Object.freeze({}); + + /** + * 确认当前 BuildSession 仍处于活动状态。 + * + * @throws Session 已关闭时抛出稳定错误。 + */ + assertActive(): void { + if (!this.#active) + throw new Error('BuildSession capabilities are no longer active.'); + } + + /** 使本 Session 已签发的全部能力立即失效。 */ + close(): void { + this.#active = false; + } +} diff --git a/packages/core/src/services/sources.ts b/packages/core/src/services/sources.ts new file mode 100644 index 0000000..a01ed0c --- /dev/null +++ b/packages/core/src/services/sources.ts @@ -0,0 +1,469 @@ +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { + SourceDirectoryRef, + SourceEntry, + SourceFileRef, + SourceService, +} from '../contracts/services.js'; +import { BuildSessionScope } from './session-scope.js'; +import { + compareCodePoints, + isInsidePath, + projectReportPath, + safeRelativePath, + SourcePathCollisionRegistry, + validatePhysicalEntry, +} from '../security/path-policy.js'; + +/** SourceRef 对应的内部对象身份授权记录。 */ +interface SourceRecord { + readonly owner: string; + readonly type: 'file' | 'directory'; + readonly root: string; + readonly physicalPath: string; + readonly reportPath: string; + readonly session: object; + readonly size?: number; + readonly sha256?: string; +} + +/** 作者源码边界使用的默认单次读取上限。 */ +const DEFAULT_SOURCE_READ_LIMIT = 16 * 1024 * 1024; + +/** 作者源码边界允许调用方请求的最大单次读取上限。 */ +const MAX_SOURCE_READ_LIMIT = 64 * 1024 * 1024; + +/** + * 流式计算来源文件的大小和 SHA-256。 + * + * @param file 已通过普通文件边界验证的绝对路径。 + * @returns 签发或重验证使用的内容指纹。 + */ +async function sourceFingerprint(file: string): Promise<{ readonly size: number; readonly sha256: string }> { + /** 增量哈希避免 SourceRef 签发时把任意大文件整体载入内存。 */ + const hash = createHash('sha256'); + /** 文件流累计读取的精确字节数。 */ + let size = 0; + await new Promise((resolve, reject) => { + /** 每次调用使用新文件流,错误不能被静默降级为部分摘要。 */ + const stream = createReadStream(file); + stream.on('data', (chunk) => { + size += typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.length; + hash.update(chunk); + }); + stream.on('error', reject); + stream.on('end', resolve); + }); + return Object.freeze({ size, sha256: hash.digest('hex') }); +} + +/** + * 校验单次读取上限。 + * + * @param requested 调用方可选请求值。 + * @returns 位于 Core 固定上限内的正整数。 + */ +function readLimit(requested: number | undefined): number { + /** 省略时使用固定默认,显式值仍不得扩大 Core 上限。 */ + const value = requested ?? DEFAULT_SOURCE_READ_LIMIT; + if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_SOURCE_READ_LIMIT) + throw new Error(`Source read limit must be an integer between 1 and ${MAX_SOURCE_READ_LIMIT}.`); + return value; +} + +/** SourceRef 使用 BuildSession WeakMap identity 实现的私有 Registry。 */ +export class SourceRegistry { + /** 当前 BuildSession 的共享存活和身份边界。 */ + readonly #scope: BuildSessionScope; + /** 所有安全报告路径的解析根。 */ + readonly #projectRoot: string; + /** Directory/File Ref 的不可伪造对象身份记录。 */ + readonly #records = new WeakMap(); + /** 已签发来源的 exact/case/NFC 冲突索引。 */ + readonly #collisions = new SourcePathCollisionRegistry(); + + /** @returns value 是否为当前 BuildSession 真实签发的 Source capability identity。 */ + isReference(value: object): boolean { + return this.#records.has(value); + } + + /** + * 创建当前 BuildSession 唯一的 Source Registry。 + * + * @param scope 当前 BuildSession capability scope。 + * @param projectRoot 工程绝对根目录。 + */ + constructor(scope: BuildSessionScope, projectRoot: string) { + this.#scope = scope; + this.#projectRoot = path.resolve(projectRoot); + } + + /** + * 登记一个 Author Source root 并为 owner 签发 DirectoryRef。 + * + * @param owner 由 Kernel 固定的 Resource owner。 + * @param physicalRoot 已配置且位于工程内的绝对 root。 + * @returns 不暴露物理绝对路径的目录能力。 + */ + async issueRoot(owner: string, physicalRoot: string): Promise { + this.#scope.assertActive(); + if (typeof owner !== 'string' || owner.length === 0) + throw new Error('Source owner must be a non-empty string.'); + /** 所有后续授权都以真实、无符号链接的来源根为界。 */ + const root = path.resolve(physicalRoot); + if (!isInsidePath(this.#projectRoot, root)) + throw new Error('Author source root must be inside the project root.'); + await validatePhysicalEntry(this.#projectRoot, root, 'directory'); + /** root 本身也必须通过 project realpath 边界。 */ + const projectReal = await fs.realpath(this.#projectRoot); + /** 来源根真实路径用于复核系统级祖先 symlink 后的边界。 */ + const rootReal = await fs.realpath(root); + if (!isInsidePath(projectReal, rootReal)) + throw new Error('Author source root realpath escapes the project root.'); + return this.#issue(owner, 'directory', root, root, projectReportPath(this.#projectRoot, root)) as SourceDirectoryRef; + } + + /** + * 为一个已验证来源签发对象身份 ref。 + * + * @param owner 来源 owner。 + * @param type 最终来源类型。 + * @param root 授权物理根。 + * @param physicalPath 来源绝对路径。 + * @param reportPath 安全工程相对路径。 + * @returns 冻结且不包含绝对路径的 ref。 + */ + #issue( + owner: string, + type: 'file' | 'directory', + root: string, + physicalPath: string, + reportPath: string, + fingerprint?: { readonly size: number; readonly sha256: string }, + ): SourceDirectoryRef | SourceFileRef { + this.#reserveCollision(reportPath, physicalPath); + /** 公开 ref 只保留 kind 与安全报告路径;类型品牌在编译期存在。 */ + const reference = Object.freeze({ kind: type === 'file' ? 'source-file' as const : 'source-directory' as const, path: reportPath }); + this.#records.set(reference, Object.freeze({ + owner, + type, + root, + physicalPath, + reportPath, + session: this.#scope.token, + ...(fingerprint === undefined ? {} : fingerprint), + })); + return reference as SourceDirectoryRef | SourceFileRef; + } + + /** + * 登记来源路径冲突,允许同一物理路径被重复签发。 + * + * @param reportPath 工程相对路径。 + * @param physicalPath 来源绝对路径。 + */ + #reserveCollision(reportPath: string, physicalPath: string): void { + this.#collisions.reserve(reportPath, physicalPath); + } + + /** + * 解析并验证一个当前 owner 持有的 SourceRef。 + * + * @param owner 当前 Context owner。 + * @param reference 未知或 SDK ref 值。 + * @param type 期望类型。 + * @returns 当前 Registry 内部记录。 + */ + #authorize(owner: string, reference: unknown, type: SourceRecord['type']): SourceRecord { + this.#scope.assertActive(); + if (typeof reference !== 'object' || reference === null) + throw new Error('Source reference is not authorized for this BuildSession.'); + /** WeakMap lookup 是运行时授权的唯一依据。 */ + const record = this.#records.get(reference); + if (record === undefined || record.session !== this.#scope.token || record.owner !== owner || record.type !== type) + throw new Error('Source reference is not authorized for this owner and BuildSession.'); + return record; + } + + /** + * 检查文件 ref 并返回 Registry 内部安全记录。 + * + * @param owner 当前 Context owner。 + * @param file 待检查文件 ref。 + * @returns 已验证文件内部记录。 + */ + authorizeFile(owner: string, file: SourceFileRef): Readonly { + return this.#authorize(owner, file, 'file'); + } + + /** + * 检查目录 ref 并返回 Registry 内部安全记录。 + * + * @param owner 当前 Context owner。 + * @param directory 待检查目录 ref。 + * @returns 已验证目录内部记录。 + */ + authorizeDirectory(owner: string, directory: SourceDirectoryRef): Readonly { + return this.#authorize(owner, directory, 'directory'); + } + + /** + * 在 Compiler/Materializer 消费前复核 FileRef 内容指纹。 + * + * @param owner 当前 Core Host owner。 + * @param file 待复核文件 ref。 + * @returns 指纹和物理边界均未变化的内部记录。 + */ + async validatedFile(owner: string, file: SourceFileRef): Promise> { + /** FileRef 对应的已授权内部记录。 */ + const record = this.#authorize(owner, file, 'file'); + await validatePhysicalEntry(record.root, record.physicalPath, 'file'); + /** 重新流式计算指纹,不受 SDK 单次读取上限影响。 */ + const fingerprint = await sourceFingerprint(record.physicalPath); + if (record.size !== fingerprint.size || record.sha256 !== fingerprint.sha256) + throw new Error(`Source file changed after its reference was issued: "${record.reportPath}".`); + return record; + } + + /** + * 在 Compiler 读取前递归拒绝授权作者树中的 symlink 和特殊文件。 + * + * @param owner 当前 Core Host owner。 + * @param directory 待复核目录 ref。 + * @returns 完整树通过物理边界检查时完成。 + */ + async validateTree(owner: string, directory: SourceDirectoryRef): Promise { + /** DirectoryRef 对应的已授权内部记录。 */ + const record = this.#authorize(owner, directory, 'directory'); + await this.#validateRecordTree(record); + } + + /** + * 在 Compiler 消费文件入口前复核其完整授权根。 + * + * @param owner 当前 Core Host owner。 + * @param file 已签发精确文件 ref。 + * @returns 授权根完整通过 symlink/特殊文件检查时完成。 + */ + async validateFileTree(owner: string, file: SourceFileRef): Promise { + /** FileRef 授权根对应的内部记录。 */ + const record = this.#authorize(owner, file, 'file'); + await this.#validateRecordTree(record); + } + + /** + * 递归检查一个已授权 Source 记录的整个物理根。 + * + * @param record 已通过 owner/Session/ref identity 授权的记录。 + */ + async #validateRecordTree(record: SourceRecord): Promise { + /** 递归枚举只做物理校验,不签发新的可观察 ref。 */ + const visit = async (directoryPath: string): Promise => { + /** 当前目录中按 code point 排序的物理目录项。 */ + const entries = (await fs.readdir(directoryPath, { withFileTypes: true })) + .sort((left, right) => compareCodePoints(left.name, right.name)); + for (const entry of entries) { + /** 当前目录项的物理绝对路径。 */ + const candidate = path.join(directoryPath, entry.name); + /** 当前目录项的安全工程相对路径。 */ + const reportPath = projectReportPath(this.#projectRoot, candidate); + /** 完整树验证同时建立 case/NFC 冲突索引,避免 Provider 枚举时才失败。 */ + this.#reserveCollision(reportPath, candidate); + if (entry.isSymbolicLink()) + throw new Error(`Author source trees must not contain symbolic links at "${reportPath}".`); + if (!entry.isFile() && !entry.isDirectory()) + throw new Error(`Author source trees must contain only regular files and directories at "${reportPath}".`); + await validatePhysicalEntry(record.root, candidate, entry.isFile() ? 'file' : 'directory'); + if (entry.isDirectory()) + await visit(candidate); + } + }; + await validatePhysicalEntry(record.root, record.root, 'directory'); + await visit(record.root); + } + + /** + * 为 owner 创建闭包绑定的 SDK SourceService。 + * + * @param owner 当前 Extension 或 Framework Resource owner。 + * @returns 不允许调用方自报 owner 的受限服务。 + */ + service(owner: string): SourceService { + /** 显式接口注解为对象方法提供 SDK 参数的上下文类型。 */ + const service: SourceService = { + /** 枚举一个已授权目录。 */ + list: (directory, options) => this.#list(owner, directory, options), + /** 为目录后代签发精确文件 ref。 */ + file: (directory, relativePath) => this.#file(owner, directory, relativePath), + /** 为目录后代签发精确目录 ref。 */ + directory: (directory, relativePath) => this.#directory(owner, directory, relativePath), + /** 在固定上限内复制来源文件字节。 */ + read: (file, options) => this.#read(owner, file, options), + /** 在固定上限内以严格 UTF-8 解码来源文件。 */ + readText: (file, options) => this.#readText(owner, file, options), + }; + return Object.freeze(service); + } + + /** + * 解析安全目录后代路径。 + * + * @param record 已授权父目录记录。 + * @param relativePath 调用方提交的 POSIX 相对路径。 + * @returns 物理路径与安全报告路径。 + */ + #descendant(record: SourceRecord, relativePath: string): { readonly physicalPath: string; readonly reportPath: string } { + /** 先按公开语法规则拒绝模糊路径。 */ + const safe = safeRelativePath(relativePath); + /** 逐 segment 使用宿主 path API 建立物理候选。 */ + const physicalPath = path.join(record.physicalPath, ...safe.split('/')); + if (!isInsidePath(record.root, physicalPath)) + throw new Error('Source path escapes its authorized root.'); + return Object.freeze({ physicalPath, reportPath: projectReportPath(this.#projectRoot, physicalPath) }); + } + + /** + * 签发一个目录后代文件 ref。 + * + * @param owner 当前 service owner。 + * @param directory 已授权父目录。 + * @param relativePath 相对父目录的安全路径。 + * @returns 当前 Session 的 SourceFileRef。 + */ + async #file(owner: string, directory: SourceDirectoryRef, relativePath: string): Promise { + /** 父目录必须是当前 owner 在本 Session 收到的原始 ref。 */ + const record = this.#authorize(owner, directory, 'directory'); + /** 后代路径解析不会暴露到公开结果。 */ + const descendant = this.#descendant(record, relativePath); + await validatePhysicalEntry(record.root, descendant.physicalPath, 'file'); + /** FileRef 签发时固定内容指纹,后续读取和 Asset 转换必须一致。 */ + const fingerprint = await sourceFingerprint(descendant.physicalPath); + return this.#issue(owner, 'file', record.root, descendant.physicalPath, descendant.reportPath, fingerprint) as SourceFileRef; + } + + /** + * 签发一个目录后代目录 ref。 + * + * @param owner 当前 service owner。 + * @param directory 已授权父目录。 + * @param relativePath 相对父目录的安全路径。 + * @returns 当前 Session 的 SourceDirectoryRef。 + */ + async #directory(owner: string, directory: SourceDirectoryRef, relativePath: string): Promise { + /** 父目录必须是当前 owner 在本 Session 收到的原始 ref。 */ + const record = this.#authorize(owner, directory, 'directory'); + /** 后代路径解析不会暴露到公开结果。 */ + const descendant = this.#descendant(record, relativePath); + await validatePhysicalEntry(record.root, descendant.physicalPath, 'directory'); + return this.#issue(owner, 'directory', record.root, descendant.physicalPath, descendant.reportPath) as SourceDirectoryRef; + } + + /** + * 稳定枚举目录中的普通文件和目录。 + * + * @param owner 当前 service owner。 + * @param directory 已授权目录。 + * @param options 是否递归枚举全部后代。 + * @returns 按 Unicode code point 路径排序的 SourceEntry。 + */ + async #list( + owner: string, + directory: SourceDirectoryRef, + options: { readonly recursive?: boolean } | undefined, + ): Promise { + /** 枚举只能从当前 owner 的原始 DirectoryRef 开始。 */ + const record = this.#authorize(owner, directory, 'directory'); + if (options !== undefined && (typeof options !== 'object' || options === null || Array.isArray(options) + || Object.keys(options).some(field => field !== 'recursive') || (options.recursive !== undefined && typeof options.recursive !== 'boolean'))) { + throw new Error('Source list options are invalid.'); + } + await validatePhysicalEntry(record.root, record.physicalPath, 'directory'); + /** 当前枚举累计的后代 entry。 */ + const results: SourceEntry[] = []; + /** + * 递归枚举一个目录并签发其直接子项。 + * + * @param current 当前物理目录。 + */ + const visit = async (current: string): Promise => { + /** readdir 结果先按 code point name 排序,再由最终完整 path 排序。 */ + const entries = (await fs.readdir(current, { withFileTypes: true })).sort((left, right) => compareCodePoints(left.name, right.name)); + for (const entry of entries) { + /** 当前子项的绝对物理路径。 */ + const physicalPath = path.join(current, entry.name); + /** 当前子项的安全工程相对报告路径。 */ + const reportPath = projectReportPath(this.#projectRoot, physicalPath); + if (entry.isSymbolicLink()) + throw new Error(`Author source trees must not contain symbolic links at "${reportPath}".`); + if (!entry.isFile() && !entry.isDirectory()) + throw new Error(`Author source trees must contain only regular files and directories at "${reportPath}".`); + await validatePhysicalEntry(record.root, physicalPath, entry.isFile() ? 'file' : 'directory'); + if (entry.isFile()) { + /** 文件 entry 包含当前 owner 的精确 file ref。 */ + const file = this.#issue(owner, 'file', record.root, physicalPath, reportPath, await sourceFingerprint(physicalPath)) as SourceFileRef; + results.push(Object.freeze({ type: 'file', name: entry.name, path: reportPath, file })); + } else { + /** 目录 entry 包含当前 owner 的精确 directory ref。 */ + const child = this.#issue(owner, 'directory', record.root, physicalPath, reportPath) as SourceDirectoryRef; + results.push(Object.freeze({ type: 'directory', name: entry.name, path: reportPath, directory: child })); + if (options?.recursive === true) + await visit(physicalPath); + } + } + }; + await visit(record.physicalPath); + return Object.freeze(results.sort((left, right) => compareCodePoints(left.path, right.path))); + } + + /** + * 复制一个已授权普通来源文件。 + * + * @param owner 当前 service owner。 + * @param file 已授权文件 ref。 + * @param options 可选读取上限。 + * @returns 不与文件系统共享的 Uint8Array 副本。 + */ + async #read( + owner: string, + file: SourceFileRef, + options: { readonly maxBytes?: number } | undefined, + ): Promise { + /** 读取只能使用当前 owner 的原始 FileRef。 */ + const record = this.#authorize(owner, file, 'file'); + /** 单次读取始终受 Core 最大值约束。 */ + const limit = readLimit(options?.maxBytes); + /** lstat/realpath 在每次读取前重新检查,阻断签发后的替换。 */ + const stat = await validatePhysicalEntry(record.root, record.physicalPath, 'file'); + if (stat.size > limit) + throw new Error(`Source file "${record.reportPath}" exceeds the requested read limit.`); + /** readFile 的 Buffer 再复制为不共享底层存储的 Uint8Array。 */ + const bytes = await fs.readFile(record.physicalPath); + if (bytes.byteLength > limit) + throw new Error(`Source file "${record.reportPath}" exceeds the requested read limit.`); + /** 签发后的普通内容修改也必须失败,不能只检查文件类型。 */ + const sha256 = createHash('sha256').update(bytes).digest('hex'); + if (record.size !== bytes.byteLength || record.sha256 !== sha256) + throw new Error(`Source file changed after its reference was issued: "${record.reportPath}".`); + return Uint8Array.from(bytes); + } + + /** + * 使用致命 UTF-8 解码读取来源文本。 + * + * @param owner 当前 service owner。 + * @param file 已授权文件 ref。 + * @param options 可选读取上限。 + * @returns 精确 UTF-8 文本。 + */ + async #readText( + owner: string, + file: SourceFileRef, + options: { readonly maxBytes?: number } | undefined, + ): Promise { + /** fatal 解码确保无效作者文本不被静默替换为 U+FFFD。 */ + return new TextDecoder('utf-8', { fatal: true }).decode(await this.#read(owner, file, options)); + } +} diff --git a/packages/core/src/services/watch.ts b/packages/core/src/services/watch.ts new file mode 100644 index 0000000..8b956fc --- /dev/null +++ b/packages/core/src/services/watch.ts @@ -0,0 +1,254 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { BuildSessionScope } from './session-scope.js'; +import { compareCodePoints, isInsidePath, projectReportPath, sourceCollisionKey } from '../security/path-policy.js'; + +/** Host 向唯一 Watch Registry 提交的单个物理观察。 */ +export interface WatchObservation { + readonly path: string; + readonly type: 'file' | 'directory'; + readonly identity?: string; + readonly pending?: boolean; +} + +/** DevSession 将物理事件映射回稳定公开 identity 的已验证 observation。 */ +export interface WatchSnapshotObservation { + readonly path: string; + readonly type: 'file' | 'directory'; + readonly identity: string; + readonly pending: boolean; +} + +/** DevSession 内部可消费的不可变 watch 快照。 */ +export interface WatchSnapshot { + readonly paths: readonly string[]; + readonly identities: readonly string[]; + readonly observations: readonly WatchSnapshotObservation[]; +} + +/** 单个 operation 已验证并冻结的 watch 集合。 */ +interface WatchRecord { + readonly paths: readonly string[]; + readonly identities: readonly string[]; + readonly observations: readonly WatchSnapshotObservation[]; + readonly session: object; +} + +/** Watch operation 使用的稳定 ID。 */ +const WATCH_OPERATION = /^[a-z0-9]+(?:[-.:/][a-z0-9]+)*$/; + +/** 外部 package observation 使用的安全 identity。 */ +const EXTERNAL_IDENTITY = /^package:(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+@[0-9A-Za-z.+-]+(?:\/[A-Za-z0-9._/-]+)?$/; + +/** + * 校验外部 observation 的稳定逻辑 identity。 + * + * @param value 调用方 identity。 + * @returns 不含物理路径的 package identity。 + */ +function externalIdentity(value: unknown): string { + if (typeof value !== 'string' || !EXTERNAL_IDENTITY.test(value) || value.split('/').includes('..')) + throw new Error('External watch observations require a safe package identity.'); + return value; +} + +/** 把尚不存在的文件规范到最深已存在祖先的 realpath 基准。 */ +async function canonicalPendingFile(candidate: string): Promise { + /** suffix 从目标向上积累,最终按原顺序接回真实祖先。 */ + const suffix: string[] = []; + /** 当前候选从最终文件开始逐级寻找已存在祖先。 */ + let current = path.normalize(candidate); + while (true) { + /** 当前祖先的文件类型决定是否已经找到安全的真实目录基准。 */ + const stat = await fs.lstat(current).catch(() => undefined); + if (stat !== undefined) { + if (stat.isSymbolicLink() || !stat.isDirectory()) + throw new Error('Pending watch file must have a regular directory ancestor.'); + /** 已存在祖先进入真实路径基准后再接回全部未创建 segment。 */ + const real = await fs.realpath(current); + return path.join(real, ...suffix.reverse()); + } + /** 父目录用于检测文件系统根并继续向上寻找。 */ + const parent = path.dirname(current); + if (parent === current) + throw new Error('Pending watch file has no existing directory ancestor.'); + suffix.push(path.basename(current)); + current = parent; + } +} + +/** BuildSession 唯一、按 owner/operation 原子替换的 Watch Registry。 */ +export class WatchRegistry { + /** 当前 Session 的存活与 identity 边界。 */ + readonly #scope: BuildSessionScope; + /** 工程真实根,用于生成公开 change identity。 */ + readonly #projectRoot: Promise; + /** owner 到 operation 再到 immutable observations 的索引。 */ + readonly #owners = new Map>(); + + /** + * 创建当前 BuildSession 唯一 Watch Registry。 + * + * @param scope 当前 Session scope。 + * @param projectRoot 工程物理根。 + */ + constructor(scope: BuildSessionScope, projectRoot: string) { + this.#scope = scope; + this.#projectRoot = fs.realpath(path.resolve(projectRoot)); + } + + /** + * 原子替换一个 owner operation 的完整 observation 集。 + * + * @param owner Kernel 固定 owner。 + * @param operation owner 内稳定 operation ID。 + * @param observations 当前操作完整依赖集合。 + */ + async replace(owner: string, operation: string, observations: readonly WatchObservation[]): Promise { + this.#scope.assertActive(); + if (typeof owner !== 'string' || owner.length === 0) + throw new Error('Watch owner must be a non-empty string.'); + if (!WATCH_OPERATION.test(operation)) + throw new Error('Watch operation must be a stable lowercase identifier.'); + if (!Array.isArray(observations)) + throw new Error('Watch observations must be an array.'); + /** 相同 physical file 只能对应一个逻辑 identity。 */ + const entries = new Map(); + /** realpath 后的 observation type 与 path/identity 同步保存。 */ + const entryTypes = new Map(); + /** pending 状态决定 DevSession readiness 与首次 add 事件语义。 */ + const entryPending = new Map(); + /** 相同逻辑 identity 也只能指向一个物理文件。 */ + const identityFiles = new Map(); + /** Dev change identity 采用与输出相同的 case/NFC 歧义规则。 */ + const collisionKeys = new Map(); + /** 系统临时目录祖先可能是 symlink,因此工程根也统一使用 realpath。 */ + const projectRoot = await this.#projectRoot; + for (const observation of [...observations]) { + if (typeof observation !== 'object' || observation === null + || Object.keys(observation).some(field => field !== 'path' && field !== 'type' && field !== 'identity' && field !== 'pending')) { + throw new Error('Watch observation must contain path, type and optional identity/pending state.'); + } + if (typeof observation.path !== 'string' || !path.isAbsolute(observation.path) || observation.path.includes('\0')) + throw new Error('Watch observation path must be absolute.'); + if (observation.type !== 'file' && observation.type !== 'directory') + throw new Error('Watch observation type must be file or directory.'); + if (observation.pending !== undefined && typeof observation.pending !== 'boolean') + throw new Error('Watch observation pending state must be boolean.'); + /** 最终路径本身不能是 symlink;package manager 的祖先链接仍被允许。 */ + const direct = await fs.lstat(observation.path).catch(() => undefined); + /** 调用方声明 pending 但文件已出现时直接升级为普通 observation。 */ + const pending = direct === undefined && observation.pending === true; + if (direct === undefined && !pending) { + throw new Error(`Watch observation must reference a regular ${observation.type}.`); + } + if (pending && observation.type !== 'file') + throw new Error('Only file watch observations may be pending.'); + if (direct !== undefined && (direct.isSymbolicLink() + || (observation.type === 'file' ? !direct.isFile() : !direct.isDirectory()))) { + throw new Error(`Watch observation must reference a regular ${observation.type}.`); + } + /** existing 与 pending 两条路径最终都进入真实祖先的同一规范基准。 */ + const real = pending ? await canonicalPendingFile(observation.path) : await fs.realpath(observation.path); + if (!pending) { + /** realpath 目标的最终普通文件状态。 */ + const stat = await fs.lstat(real).catch(() => undefined); + if (stat === undefined || stat.isSymbolicLink() + || (observation.type === 'file' ? !stat.isFile() : !stat.isDirectory())) { + throw new Error(`Watch observation must reference a regular ${observation.type}.`); + } + } + /** 显式 package identity 在 node_modules 位于工程内时也不能退化为物理路径。 */ + const identity = observation.identity === undefined + ? isInsidePath(projectRoot, real) + ? projectReportPath(projectRoot, real) || '.' + : externalIdentity(undefined) + : externalIdentity(observation.identity); + /** 当前物理路径已登记的可选先前 identity。 */ + const previous = entries.get(real); + if (previous !== undefined && previous !== identity) + throw new Error('One watch file must not have multiple logical identities.'); + /** 一个 identity 指向多个 store copy 会让 change event 变得含糊。 */ + const previousFile = identityFiles.get(identity); + if (previousFile !== undefined && previousFile !== real) + throw new Error('One watch identity must not reference multiple files.'); + /** 大小写或 Unicode 归一化后相同的 identity 同样拒绝。 */ + const collision = sourceCollisionKey(identity); + /** 当前折叠键已占用的原始 identity。 */ + const previousIdentity = collisionKeys.get(collision); + if (previousIdentity !== undefined && previousIdentity !== identity) + throw new Error('Watch identities contain a case or Unicode normalization collision.'); + entries.set(real, identity); + entryTypes.set(real, observation.type); + entryPending.set(real, pending); + identityFiles.set(identity, real); + collisionKeys.set(collision, identity); + } + /** physical file 与公开 identity 分别稳定排序。 */ + const paths = Object.freeze([...entries.keys()].sort(compareCodePoints)); + /** 对外变更 identity 去重后的稳定集合。 */ + const identities = Object.freeze([...new Set(entries.values())].sort(compareCodePoints)); + /** path/identity/type 关系由 DevSession 保留,不能退化为两个无关数组。 */ + const snapshotObservations = Object.freeze([...entries.entries()] + .sort(([left], [right]) => compareCodePoints(left, right)) + .map(([observedPath, identity]) => Object.freeze({ + path: observedPath, + identity, + type: entryTypes.get(observedPath)!, + pending: entryPending.get(observedPath)!, + }))); + /** 当前 owner 已存在或新建的 operation map。 */ + const operations = this.#owners.get(owner) ?? new Map(); + operations.set(operation, Object.freeze({ paths, identities, observations: snapshotObservations, session: this.#scope.token })); + this.#owners.set(owner, operations); + } + + /** + * 删除已不再存在的 owner operation watch 集。 + * + * @param owner 当前 operation owner。 + * @param operation 稳定 operation ID。 + */ + remove(owner: string, operation: string): void { + this.#scope.assertActive(); + this.#owners.get(owner)?.delete(operation); + } + + /** + * 返回当前 Session 全部 owner/operation 合并后的不可变快照。 + * + * @returns 物理 watcher 输入和安全 change identities。 + */ + snapshot(): WatchSnapshot { + this.#scope.assertActive(); + /** 所有有效 record 合并去重。 */ + const paths = new Set(); + /** 所有公开安全 change identities 的并集。 */ + const identities = new Set(); + /** 物理 path 与公开 identity/type 的完整映射。 */ + const observations = new Map(); + for (const operations of this.#owners.values()) { + for (const record of operations.values()) { + if (record.session !== this.#scope.token) + throw new Error('Watch observation belongs to another BuildSession.'); + for (const observedPath of record.paths) + paths.add(observedPath); + for (const identity of record.identities) + identities.add(identity); + for (const observation of record.observations) { + /** 跨 owner operation 的相同物理路径也必须保持同一 identity/type。 */ + const previous = observations.get(observation.path); + if (previous !== undefined && (previous.identity !== observation.identity || previous.type !== observation.type + || previous.pending !== observation.pending)) + throw new Error('One watch file must not have multiple logical observations.'); + observations.set(observation.path, observation); + } + } + } + return Object.freeze({ + paths: Object.freeze([...paths].sort(compareCodePoints)), + identities: Object.freeze([...identities].sort(compareCodePoints)), + observations: Object.freeze([...observations.values()].sort((left, right) => compareCodePoints(left.path, right.path))), + }); + } +} diff --git a/packages/core/src/services/work-directories.ts b/packages/core/src/services/work-directories.ts new file mode 100644 index 0000000..3e88b51 --- /dev/null +++ b/packages/core/src/services/work-directories.ts @@ -0,0 +1,121 @@ +import { createHash } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { BuildSessionScope } from './session-scope.js'; +import { isInsidePath, safeRelativePath, validatePhysicalEntry } from '../security/path-policy.js'; + +/** WorkDirectoryRegistry 私有的不可伪造目录句柄。 */ +export interface WorkDirectoryHandle { + readonly kind: 'work-directory'; +} + +/** Work directory 句柄对应的内部授权记录。 */ +interface WorkDirectoryRecord { + readonly owner: string; + readonly directory: string; + readonly session: object; +} + +/** Core 为每个 owner 管理唯一临时工作目录的私有 Registry。 */ +export class WorkDirectoryRegistry { + /** 当前 BuildSession 的共享存活与身份边界。 */ + readonly #scope: BuildSessionScope; + /** 所有 owner workDir 的唯一物理父目录。 */ + readonly #root: string; + /** 已签发句柄的对象身份记录。 */ + readonly #records = new WeakMap(); + /** 每个 owner 恰好一个工作目录。 */ + readonly #owners = new Map(); + + /** + * 创建一个只属于当前 BuildSession 的 WorkDir Registry。 + * + * @param scope 当前 BuildSession capability scope。 + * @param root Core 已创建的临时根目录。 + */ + constructor(scope: BuildSessionScope, root: string) { + this.#scope = scope; + this.#root = path.resolve(root); + } + + /** + * 为 owner 创建或返回其唯一工作目录句柄。 + * + * @param owner 由 Kernel 固定的稳定 owner。 + * @returns 不暴露物理路径的私有句柄。 + */ + async directory(owner: string): Promise { + this.#scope.assertActive(); + if (typeof owner !== 'string' || owner.length === 0) + throw new Error('Work directory owner must be a non-empty string.'); + /** 同一 owner 重复请求必须观察相同授权身份。 */ + const existing = this.#owners.get(owner); + if (existing !== undefined) + return existing; + await fs.mkdir(this.#root, { recursive: true, mode: 0o700 }); + /** owner hash 避免把任意 owner 文本直接解释为路径。 */ + const directory = path.join(this.#root, createHash('sha256').update(owner).digest('hex')); + await fs.mkdir(directory, { recursive: false, mode: 0o700 }); + /** 公开句柄只有无路径语义的 kind。 */ + const handle = Object.freeze({ kind: 'work-directory' as const }); + this.#records.set(handle, Object.freeze({ owner, directory, session: this.#scope.token })); + this.#owners.set(owner, handle); + return handle; + } + + /** + * 解析 owner workDir 内的 Core 私有相对路径。 + * + * @param owner 当前 Context 绑定的 owner。 + * @param handle 当前 owner 的目录句柄。 + * @param relative 待解析的安全 POSIX 路径。 + * @returns 仍位于当前 workDir 内的绝对路径。 + */ + resolve(owner: string, handle: WorkDirectoryHandle, relative: string): string { + this.#scope.assertActive(); + /** 只有当前 Registry WeakMap 中的原始对象才是有效句柄。 */ + const record = this.#records.get(handle); + if (record === undefined || record.session !== this.#scope.token || record.owner !== owner) + throw new Error('Work directory handle is not authorized for this owner and BuildSession.'); + /** 物理解析前先拒绝路径语法歧义。 */ + const safe = safeRelativePath(relative); + /** POSIX 作者路径按宿主分隔符逐 segment 拼接。 */ + const candidate = path.join(record.directory, ...safe.split('/')); + if (!isInsidePath(record.directory, candidate)) + throw new Error('Work directory path escapes its owner root.'); + return candidate; + } + + /** + * 为 Core Host 返回已授权 owner workDir 的物理根。 + * + * @param owner 当前 Host owner。 + * @param handle 当前 owner 的不可伪造句柄。 + * @returns 仅 Core 私有实现可见的绝对根路径。 + */ + physicalRoot(owner: string, handle: WorkDirectoryHandle): string { + this.#scope.assertActive(); + /** WeakMap 记录同时复核 owner、Session 与对象 identity。 */ + const record = this.#records.get(handle); + if (record === undefined || record.session !== this.#scope.token || record.owner !== owner) + throw new Error('Work directory handle is not authorized for this owner and BuildSession.'); + return record.directory; + } + + /** + * 验证一个生成文件确实来自指定 owner 的 workDir。 + * + * @param owner 当前生成操作 owner。 + * @param handle owner workDir 句柄。 + * @param relative workDir-relative 生成文件路径。 + * @returns 已验证普通文件的绝对路径和授权根。 + */ + async generatedFile(owner: string, handle: WorkDirectoryHandle, relative: string): Promise<{ readonly file: string; readonly root: string }> { + /** resolve 同时完成 Session、owner 和对象身份验证。 */ + const file = this.resolve(owner, handle, relative); + /** 重新读取授权记录以取得不向 Integration 暴露的物理根。 */ + const record = this.#records.get(handle)!; + await validatePhysicalEntry(record.directory, file, 'file'); + return Object.freeze({ file, root: record.directory }); + } +} diff --git a/packages/core/test/compiler/compiler-managed.test.ts b/packages/core/test/compiler/compiler-managed.test.ts new file mode 100644 index 0000000..f9c469a --- /dev/null +++ b/packages/core/test/compiler/compiler-managed.test.ts @@ -0,0 +1,614 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { VERSION } from 'rolldown'; +import type { + CompileJob, + ManagedRolldownPlugin, +} from '../../src/contracts/index.js'; +import { CompilerHost } from '../../src/compiler/compiler-service.js'; +import { AssetRegistry } from '../../src/services/assets.js'; +import { BuildSessionScope } from '../../src/services/session-scope.js'; +import { SourceRegistry } from '../../src/services/sources.js'; +import { WatchRegistry } from '../../src/services/watch.js'; +import { WorkDirectoryRegistry } from '../../src/services/work-directories.js'; + +/** Compiler Host 测试创建的临时工程根。 */ +const roots: string[] = []; + +/** + * 创建 owner-scoped Compiler Host 测试夹具。 + * + * @param owner 当前集成 owner。 + * @returns 来源、service、Asset Registry 与 Watch 记录。 + */ +async function fixture(owner = 'extension:managed') { + /** 当前测试独占的工程根。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-compiler-managed-')); + roots.push(root); + /** 受管作者源码根。 */ + const sourceRoot = path.join(root, 'src', 'owned'); + await fs.mkdir(sourceRoot, { recursive: true }); + await fs.writeFile(path.join(sourceRoot, 'main.ts'), [ + 'import { message } from "./message.ts";', + 'export const value: string = message;', + ].join('\n')); + await fs.writeFile(path.join(sourceRoot, 'message.ts'), 'export const message: string = "source";\n'); + /** 当前 BuildSession 能力作用域。 */ + const scope = new BuildSessionScope(); + /** SourceRef 唯一签发注册表。 */ + const sources = new SourceRegistry(scope, root); + /** owner workDir 唯一签发注册表。 */ + const work = new WorkDirectoryRegistry(scope, path.join(root, '.work')); + /** AssetRef 唯一签发注册表。 */ + const assets = new AssetRegistry(scope, sources, work); + /** 当前 Session 唯一 Watch Registry。 */ + const watch = new WatchRegistry(scope, root); + /** 当前 owner 的作者来源根 ref。 */ + const sourceDirectory = await sources.issueRoot(owner, sourceRoot); + /** 当前 owner 来源 service。 */ + const sourceService = sources.service(owner); + /** 当前 owner 精确入口 ref。 */ + const entry = await sourceService.file(sourceDirectory, 'main.ts'); + /** BuildSession 唯一 Compiler Host。 */ + const host = new CompilerHost({ + projectRoot: root, + sources, + workDirectories: work, + assets, + watch, + }); + return { + root, + sourceRoot, + sourceDirectory, + sourceService, + entry, + assets, + service: await host.service(owner), + watch, + owner, + scope, + }; +} + +/** + * 在 fixture 中写入一个具备完整法律材料的真实 package dependency。 + * + * @param root 当前测试工程根。 + */ +async function writeLicensedPackage(root: string): Promise { + /** 可由 Rolldown bare import 解析的 package 根。 */ + const packageRoot = path.join(root, 'node_modules', 'managed-license-fixture'); + await fs.mkdir(packageRoot, { recursive: true }); + await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ + name: 'managed-license-fixture', + version: '1.2.3', + type: 'module', + exports: './index.js', + license: 'MIT', + })); + await fs.writeFile(path.join(packageRoot, 'index.js'), 'export const licensed = "licensed";\n'); + await fs.writeFile(path.join(packageRoot, 'LICENSE'), 'Managed license fixture.\n'); +} + +/** + * 创建一个使用受管源码的最小 managed Job。 + * + * @param entry 当前 owner 的 SourceFileRef。 + * @param overrides 需要覆盖的 Job 字段。 + * @returns 可直接交给 CompilerService 的 Job。 + */ +function managedJob( + entry: Awaited['file']>>, + overrides: Record = {}, +): CompileJob<'managed-rolldown'> { + return { + id: 'managed-job', + profile: 'managed-rolldown', + entries: { main: { type: 'source', source: entry, mode: 0o755 } }, + options: { + outputs: [{ id: 'esm', options: { format: 'es', entryFileNames: 'main.mjs' } }], + policy: { licenses: 'ignore' }, + }, + ...overrides, + } as CompileJob<'managed-rolldown'>; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('managed Rolldown Compiler Host', () => { + it('executes real input/output Plugin lifecycles, multi-output and signs GeneratedAsset refs', async () => { + /** 当前完整 Compiler Host 夹具。 */ + const current = await fixture(); + /** Plugin lifecycle 的精确执行顺序。 */ + const calls: string[] = []; + /** input Plugin 引入 resolve/load/transform 真实生命周期。 */ + const inputPlugin: ManagedRolldownPlugin = { + name: 'input-lifecycle', + options: options => (calls.push('options'), options), + buildStart: () => { + calls.push('build-start'); + }, + resolveId(source) { + if (source === 'virtual:extra') + return '\0test:extra'; + return null; + }, + load(id) { + if (id === '\0test:extra') { + calls.push('load'); + return 'export const extra = "extra";'; + } + return null; + }, + transform: { + filter: { id: /main\.ts$/u }, + handler(code) { + calls.push('transform'); + return `${code}\nimport { extra } from "virtual:extra"; export const combined = value + extra;`; + }, + }, + buildEnd: () => { + calls.push('build-end'); + }, + closeBundle: () => { + calls.push('close-bundle'); + }, + }; + /** 两个 output Plugin 证明声明顺序与各自 render/generate hook。 */ + const outputPlugin = (id: string): ManagedRolldownPlugin => ({ + name: `output-${id}`, + outputOptions: options => (calls.push(`output-options:${id}`), options), + renderChunk: code => (calls.push(`render:${id}`), { code: `${code}\n/* ${id} */`, map: null }), + generateBundle: () => { + calls.push(`generate:${id}`); + }, + }); + /** Promise/nested array Plugin option 组合。 */ + const promisedPlugin = Promise.resolve(inputPlugin); + const job = managedJob(current.entry, { + options: { + inputOptions: { plugins: [[false, promisedPlugin]] }, + outputs: [ + { id: 'esm', options: { format: 'es', entryFileNames: 'main.mjs', plugins: [outputPlugin('esm')] } }, + { id: 'cjs', options: { format: 'cjs', entryFileNames: 'main.cjs', plugins: [outputPlugin('cjs')] } }, + ], + policy: { licenses: 'ignore' }, + }, + }); + + const result = await current.service.compile(job); + + expect(current.service.engine).toEqual({ name: 'rolldown', version: VERSION }); + expect(result.engine).toEqual({ name: 'rolldown', version: VERSION }); + expect(result.outputs.map(output => [output.outputId, output.fileName, output.isEntry])).toEqual([ + ['esm', 'main.mjs', true], + ['cjs', 'main.cjs', true], + ]); + expect(result.outputs.every(output => output.asset.kind === 'generated-asset')).toBe(true); + expect(calls).toContain('options'); + expect(calls).toContain('load'); + expect(calls).toContain('transform'); + expect(calls.indexOf('generate:esm')).toBeLessThan(calls.indexOf('generate:cjs')); + expect(calls.at(-1)).toBe('close-bundle'); + expect(result.modules.every(module => !module.id.includes(current.root))).toBe(true); + expect(current.watch.snapshot().paths).toContain(await fs.realpath(path.join(current.sourceRoot, 'message.ts'))); + /** GeneratedAsset 字节可由当前 owner 通过 Registry 安全读取。 */ + const bytes = await current.assets.service(current.owner).read(result.outputs[0]!.asset); + expect(new TextDecoder().decode(bytes)).toContain('/* esm */'); + }); + + it('supports virtual entries resolved from an authorized SourceDirectoryRef', async () => { + /** 当前完整 Compiler Host 夹具。 */ + const current = await fixture(); + /** 虚拟 entry 的相对 import 必须从 SourceDirectoryRef 解析。 */ + const result = await current.service.compile(managedJob(current.entry, { + entries: { + virtual: { + type: 'virtual', + code: 'export { message } from "./message.ts";', + resolveFrom: current.sourceDirectory, + }, + }, + })); + + expect(result.outputs).toHaveLength(1); + expect(result.modules.some(module => module.id === 'src/owned/message.ts')).toBe(true); + expect(JSON.stringify(result)).not.toContain(current.root); + }); + + it('normalizes ordinary deterministic output and audits every emitted byte kind', async () => { + const current = await fixture(); + /** 默认非压缩 Rolldown region 不得让普通 deterministic Job 自我拒绝。 */ + const success = await current.service.compile(managedJob(current.entry, { + id: 'deterministic-success', + options: { + outputs: [{ id: 'esm', options: { format: 'es', entryFileNames: 'main.mjs' } }], + policy: { deterministic: true, licenses: 'ignore' }, + }, + })); + const successBytes = await current.assets.service(current.owner).read(success.outputs[0]!.asset); + expect(new TextDecoder().decode(successBytes)).not.toContain(current.root); + + /** renderChunk 注入工程根必须在签发 Asset 前失败。 */ + const chunkLeak: ManagedRolldownPlugin = { + name: 'chunk-path-leak', + renderChunk: code => ({ code: `${code}\nglobalThis.__managedRoot = ${JSON.stringify(current.root)};`, map: null }), + }; + await expect(current.service.compile(managedJob(current.entry, { + id: 'deterministic-chunk-leak', + options: { + outputs: [{ id: 'esm', options: { format: 'es', plugins: [chunkLeak] } }], + policy: { deterministic: true, licenses: 'ignore' }, + }, + }))).rejects.toThrow('absolute build path'); + + /** generateBundle 发出的普通 Asset 同样属于 deterministic 字节闭包。 */ + const assetLeak: ManagedRolldownPlugin = { + name: 'asset-path-leak', + generateBundle() { + this.emitFile({ type: 'asset', fileName: 'leak.txt', source: new TextEncoder().encode(current.root) }); + }, + }; + await expect(current.service.compile(managedJob(current.entry, { + id: 'deterministic-asset-leak', + options: { + outputs: [{ id: 'esm', options: { format: 'es', plugins: [assetLeak] } }], + policy: { deterministic: true, licenses: 'ignore' }, + }, + }))).rejects.toThrow('absolute build path'); + + await expect(current.service.compile(managedJob(current.entry, { + id: 'deterministic-disabled-normalization', + options: { + outputs: [{ id: 'esm', options: { format: 'es', minify: false } }], + policy: { deterministic: true, licenses: 'ignore' }, + }, + }))).rejects.toThrow('cannot disable whitespace normalization'); + }); + + it('retains an authorized watch file that does not exist yet', async () => { + const current = await fixture(); + const pending = path.join(current.sourceRoot, 'future.config.ts'); + /** addWatchFile 的标准 missing-file 用法必须进入 pending Watch snapshot。 */ + const plugin: ManagedRolldownPlugin = { + name: 'pending-watch-file', + buildStart() { + this.addWatchFile(pending); + }, + }; + + await current.service.compile(managedJob(current.entry, { + options: { + inputOptions: { plugins: [plugin] }, + outputs: [{ id: 'esm', options: { format: 'es' } }], + policy: { licenses: 'ignore' }, + }, + })); + + expect(current.watch.snapshot().observations).toContainEqual({ + path: path.join(await fs.realpath(current.sourceRoot), 'future.config.ts'), + type: 'file', + identity: 'src/owned/future.config.ts', + pending: true, + }); + }); + + it('rejects pending watch escape and symlink ancestor paths', async () => { + const escaped = await fixture(); + /** project 内但 owner source root 外的 missing file 不属于授权恢复入口。 */ + const escapePlugin: ManagedRolldownPlugin = { + name: 'pending-watch-escape', + buildStart() { this.addWatchFile(path.join(escaped.root, 'outside.config.ts')); }, + }; + await expect(escaped.service.compile(managedJob(escaped.entry, { + id: 'pending-watch-escape', + options: { + inputOptions: { plugins: [escapePlugin] }, + outputs: [{ id: 'esm', options: { format: 'es' } }], + policy: { licenses: 'ignore' }, + }, + }))).rejects.toThrow('outside its authorized module graph'); + + const linked = await fixture(); + /** 作者 source root 内的 symlink 祖先不能把 pending file 指向其他树。 */ + const external = path.join(linked.root, 'external'); + await fs.mkdir(external); + await fs.symlink(external, path.join(linked.sourceRoot, 'linked'), 'dir'); + const symlinkPlugin: ManagedRolldownPlugin = { + name: 'pending-watch-symlink', + buildStart() { this.addWatchFile(path.join(linked.sourceRoot, 'linked/future.config.ts')); }, + }; + await expect(linked.service.compile(managedJob(linked.entry, { + id: 'pending-watch-symlink', + options: { + inputOptions: { plugins: [symlinkPlugin] }, + outputs: [{ id: 'esm', options: { format: 'es' } }], + policy: { licenses: 'ignore' }, + }, + }))).rejects.toThrow(/(?:symbolic links|regular directory ancestor)/u); + }); + + it('accepts only an authorized SourceFileRef for explicit tsconfig', async () => { + /** 当前完整 Compiler Host 夹具。 */ + const current = await fixture(); + await fs.writeFile(path.join(current.sourceRoot, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { useDefineForClassFields: true }, + })); + /** 当前 owner 精确签发的 tsconfig SourceFileRef。 */ + const tsconfig = await current.sourceService.file(current.sourceDirectory, 'tsconfig.json'); + + await expect(current.service.compile(managedJob(current.entry, { + options: { + inputOptions: { tsconfig }, + outputs: [{ id: 'esm', options: { format: 'es' } }], + }, + }))).resolves.toMatchObject({ job: 'managed-job' }); + + /** 字符串不是 SourceRef capability,即使工程内存在也必须拒绝。 */ + await expect(current.service.compile(managedJob(current.entry, { + id: 'string-tsconfig', + options: { + inputOptions: { tsconfig: 'src/owned/tsconfig.json' }, + outputs: [{ id: 'esm', options: { format: 'es' } }], + }, + }))).rejects.toThrow('SourceFileRef'); + }); + + it('snapshots nested options and Plugin hook shells before promised Plugins yield', async () => { + /** 当前完整 Compiler Host 夹具。 */ + const current = await fixture(); + /** 原始输出参数将在 Plugin Promise 解析期间修改。 */ + const outputOptions: Record = { format: 'es', entryFileNames: 'stable.mjs' }; + /** object-hook 外壳在 await 后修改也不得换掉当次 handler/filter。 */ + let transformed = 0; + const hook = { + filter: { id: /main\.ts$/u }, + handler(code: string) { + transformed += 1; + return code; + }, + }; + /** Promise 解析前留出一个 microtask mutation 窗口。 */ + /** 已解析 thenable 仍会使 Compiler 进入 await 边界。 */ + const plugin = { name: 'snapshot-plugin', transform: hook }; + const job = managedJob(current.entry, { + options: { + inputOptions: { resolve: { extensions: ['.ts'] }, plugins: [plugin] }, + outputs: [{ id: 'esm', options: outputOptions }], + policy: { licenses: 'ignore' }, + }, + }); + const pending = current.service.compile(job); + outputOptions.entryFileNames = 'mutated.mjs'; + hook.handler = () => { + throw new Error('mutated hook must not run'); + }; + hook.filter.id = /never-match/u; + + const result = await pending; + + expect(result.outputs[0]?.fileName).toBe('stable.mjs'); + expect(transformed).toBe(1); + }); + + it('rejects forbidden/unknown fields and hooks before any Plugin executes', async () => { + /** 当前完整 Compiler Host 夹具。 */ + const current = await fixture(); + /** 被禁 Plugin 不得触发任何生命周期。 */ + let executed = false; + const forbiddenPlugin = { + name: 'forbidden', + buildStart: () => { + executed = true; + }, + writeBundle: () => { + executed = true; + }, + }; + + await expect(current.service.compile(managedJob(current.entry, { + options: { + inputOptions: { plugins: [forbiddenPlugin] }, + outputs: [{ id: 'esm', options: { format: 'es' } }], + }, + }))).rejects.toThrow('writeBundle'); + expect(executed).toBe(false); + + /** 新 Job ID 验证嵌套 watch/dev 字段在引擎前失败。 */ + await expect(current.service.compile(managedJob(current.entry, { + id: 'nested-dev', + options: { + inputOptions: { experimental: { incrementalBuild: true } }, + outputs: [{ id: 'esm', options: { format: 'es' } }], + }, + }))).rejects.toThrow('incrementalBuild'); + await expect(current.service.compile(managedJob(current.entry, { + id: 'unknown-field', + options: { + inputOptions: { futureDirectWrite: true }, + outputs: [{ id: 'esm', options: { format: 'es' } }], + }, + }))).rejects.toThrow('not supported'); + }); + + it('blocks options/outputOptions attempts to rewrite Core-owned fields and still closes the bundle', async () => { + /** 当前完整 Compiler Host 夹具。 */ + const current = await fixture(); + /** closeBundle 证明 generate 失败后仍由 Host 关闭 bundle。 */ + let closed = false; + const plugin: ManagedRolldownPlugin = { + name: 'rewrite-input', + options(options) { + return { ...options, cwd: '/tmp/escape' }; + }, + closeBundle: () => { + closed = true; + }, + }; + await expect(current.service.compile(managedJob(current.entry, { + options: { + inputOptions: { plugins: [plugin] }, + outputs: [{ id: 'esm', options: { format: 'es' } }], + }, + }))).rejects.toThrow('Core-managed field "cwd"'); + /** options hook 在 rolldown() 返回 bundle 前失败,Host 尚无可关闭句柄。 */ + expect(closed).toBe(false); + + /** 输出 hook 同样不能利用 generate-only 操作绑定物理 dir。 */ + const outputPlugin: ManagedRolldownPlugin = { + name: 'rewrite-output', + outputOptions(options) { + return { ...options, dir: '/tmp/escape' }; + }, + }; + await expect(current.service.compile(managedJob(current.entry, { + id: 'rewrite-output', + options: { + outputs: [{ id: 'esm', options: { format: 'es', plugins: [outputPlugin] } }], + }, + }))).rejects.toThrow('Core-managed field "dir"'); + /** outputOptions 在完整 build 前失败,Rolldown 不调用 Plugin closeBundle hook。 */ + expect(closed).toBe(false); + + /** render 阶段已完成 buildStart,此时 Host finally 关闭会调用 closeBundle。 */ + const closeObserver: ManagedRolldownPlugin = { + name: 'close-observer', + closeBundle: () => { + closed = true; + }, + }; + const renderFailure: ManagedRolldownPlugin = { + name: 'render-failure', + renderChunk() { + throw new Error('render failed'); + }, + }; + await expect(current.service.compile(managedJob(current.entry, { + id: 'render-failure', + options: { + inputOptions: { plugins: [closeObserver] }, + outputs: [{ id: 'esm', options: { format: 'es', plugins: [renderFailure] } }], + }, + }))).rejects.toThrow('render failed'); + expect(closed).toBe(true); + }); + + it('audits source escape, output paths, unresolved imports and generated provenance after Plugins', async () => { + /** 当前完整 Compiler Host 夹具。 */ + const current = await fixture(); + /** 工程外文件不属于作者来源或 package 边界。 */ + const outside = path.join(current.root, 'outside.ts'); + await fs.writeFile(outside, 'export const secret = true;\n'); + const escapePlugin: ManagedRolldownPlugin = { + name: 'source-escape', + resolveId(source) { + return source === 'escape' ? outside : null; + }, + transform(code, id) { + return id.endsWith('main.ts') ? `${code}\nimport "escape";` : null; + }, + }; + await expect(current.service.compile(managedJob(current.entry, { + options: { + inputOptions: { plugins: [escapePlugin] }, + outputs: [{ id: 'esm', options: { format: 'es' } }], + }, + }))).rejects.toThrow('escaped authorized sources'); + + /** generateBundle 在最后修改 fileName 仍会被 Host 输出审计拒绝。 */ + const pathPlugin: ManagedRolldownPlugin = { + name: 'path-escape', + generateBundle(_options, bundle) { + const chunk = Object.values(bundle).find(item => item.type === 'chunk'); + if (chunk !== undefined) + chunk.fileName = '../escape.mjs'; + }, + }; + await expect(current.service.compile(managedJob(current.entry, { + id: 'path-escape', + options: { outputs: [{ id: 'esm', options: { format: 'es', plugins: [pathPlugin] } }] }, + }))).rejects.toThrow('output'); + + /** external 静态 import 在 reject 策略下不得伪装成成功 Bundle。 */ + const unresolvedPlugin: ManagedRolldownPlugin = { + name: 'unresolved', + transform(code, id) { + return id.endsWith('main.ts') ? `${code}\nimport "missing-runtime";` : null; + }, + }; + await expect(current.service.compile(managedJob(current.entry, { + id: 'unresolved', + options: { + inputOptions: { external: ['missing-runtime'], plugins: [unresolvedPlugin] }, + outputs: [{ id: 'esm', options: { format: 'es' } }], + policy: { unresolvedImports: 'reject', licenses: 'ignore' }, + }, + }))).rejects.toThrow('unresolved import'); + + /** 成功结果的 compile provenance 只保留逻辑 module identity。 */ + const success = await current.service.compile(managedJob(current.entry, { id: 'provenance' })); + const metadata = current.assets.describe(current.owner, success.outputs[0]!.asset); + expect(metadata.origin).toMatchObject({ type: 'compile', job: 'provenance', output: 'esm' }); + expect(JSON.stringify(metadata)).not.toContain(current.root); + }); + + it('rejects forged, cross-owner, mutated and symlink-replaced SourceRefs', async () => { + /** 当前 owner 的 Compiler Host 夹具。 */ + const current = await fixture('extension:a'); + /** 等形复制 ref 没有 WeakMap 授权。 */ + const forged = Object.freeze({ ...current.entry }) as typeof current.entry; + await expect(current.service.compile(managedJob(forged))).rejects.toThrow('not authorized'); + + /** 另一 owner 的 CompilerService 不能消费 a 的 SourceRef。 */ + const other = await fixture('extension:b'); + await expect(other.service.compile(managedJob(current.entry))).rejects.toThrow('not authorized'); + + /** 普通内容修改在 Rolldown 读取前由 Source Registry 指纹拒绝。 */ + await fs.writeFile(path.join(current.sourceRoot, 'main.ts'), 'export const changed = true;\n'); + await expect(current.service.compile(managedJob(current.entry, { id: 'mutated' }))).rejects.toThrow('changed after'); + + /** 作者树中任何 symlink 都使整个 Job 失败。 */ + const symlinked = await fixture('extension:symlinked'); + await fs.symlink(path.join(symlinked.sourceRoot, 'message.ts'), path.join(symlinked.sourceRoot, 'linked.ts')); + await expect(symlinked.service.compile(managedJob(symlinked.entry))).rejects.toThrow('symbolic links'); + }); + + it('collects strict licenses by default and allows explicit managed ignore', async () => { + /** 默认 policy 使用实际 package graph 生成相邻法律材料。 */ + const strict = await fixture(); + await writeLicensedPackage(strict.root); + const plugin: ManagedRolldownPlugin = { + name: 'licensed-import', + /** 把真实 package 引入 managed graph。 */ + transform(code, id) { + return id.endsWith('main.ts') ? `${code}\nimport { licensed } from "managed-license-fixture"; export { licensed };` : null; + }, + }; + const result = await strict.service.compile(managedJob(strict.entry, { + options: { + inputOptions: { plugins: [plugin] }, + outputs: [{ id: 'esm', options: { format: 'es', entryFileNames: 'main.mjs' } }], + }, + })); + const license = result.outputs.find(output => output.type === 'licenses')!; + const text = new TextDecoder().decode(await strict.assets.service(strict.owner).read(license.asset)); + expect(text).toContain('managed-license-fixture@1.2.3'); + + /** 显式 ignore 不生成 Core 法律材料。 */ + const ignored = await fixture(); + await writeLicensedPackage(ignored.root); + const ignoredResult = await ignored.service.compile(managedJob(ignored.entry, { + options: { + inputOptions: { plugins: [plugin] }, + outputs: [{ id: 'esm', options: { format: 'es', entryFileNames: 'main.mjs' } }], + policy: { licenses: 'ignore' }, + }, + })); + expect(ignoredResult.outputs.some(output => output.type === 'licenses')).toBe(false); + }); +}); diff --git a/packages/core/test/compiler/compiler-portable.test.ts b/packages/core/test/compiler/compiler-portable.test.ts new file mode 100644 index 0000000..e6b299f --- /dev/null +++ b/packages/core/test/compiler/compiler-portable.test.ts @@ -0,0 +1,260 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { CompileJob, SourceFileRef } from '../../src/contracts/index.js'; +import { CompilerHost } from '../../src/compiler/compiler-service.js'; +import { packageScope } from '../../src/compiler/managed/boundary.js'; +import { AssetRegistry } from '../../src/services/assets.js'; +import { BuildSessionScope } from '../../src/services/session-scope.js'; +import { SourceRegistry } from '../../src/services/sources.js'; +import { WatchRegistry } from '../../src/services/watch.js'; +import { WorkDirectoryRegistry } from '../../src/services/work-directories.js'; + +/** portable Compiler 测试创建的临时工程根。 */ +const roots: string[] = []; + +/** + * 写入一个真实可由 Rolldown bare-import 解析的第三方包。 + * + * @param root fixture 工程根。 + * @param options 可选缺失/非法法律材料状态。 + * @returns package 真正物理根。 + */ +async function writeDependency( + root: string, + options: { readonly license?: string | false; readonly legal?: boolean; readonly symlink?: boolean } = {}, +): Promise { + /** symlink 模式模拟 pnpm node_modules 链接到包管理器 store。 */ + const packageRoot = options.symlink === true + ? path.join(root, '.store', 'portable-fixture-dependency') + : path.join(root, 'node_modules', 'portable-fixture-dependency'); + await fs.mkdir(packageRoot, { recursive: true }); + await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ + name: 'portable-fixture-dependency', + version: '3.2.1', + type: 'module', + exports: './index.js', + ...(options.license === false ? {} : { license: options.license ?? 'MIT' }), + })); + await fs.writeFile(path.join(packageRoot, 'index.js'), [ + '/*! @license MIT */', + 'export const dependencyMessage = "dependency-ready";', + ].join('\n')); + if (options.legal !== false) { + await fs.writeFile(path.join(packageRoot, 'LICENSE'), 'Portable fixture dependency license.\n'); + await fs.writeFile(path.join(packageRoot, 'NOTICE.md'), 'Portable fixture notice.\n'); + } + if (options.symlink === true) { + const modules = path.join(root, 'node_modules'); + await fs.mkdir(modules, { recursive: true }); + await fs.symlink(packageRoot, path.join(modules, 'portable-fixture-dependency'), 'dir'); + } + return packageRoot; +} + +/** + * 创建 owner-scoped portable Compiler fixture。 + * + * @param dependency 是否写入依赖及其法律材料。 + * @returns SourceRef、CompilerService、AssetRegistry 与 watch 记录。 + */ +async function fixture(dependency: Parameters[1] | false = {}) { + /** 当前测试独占工程根。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-compiler-portable-')); + roots.push(root); + /** portable 作者源码树。 */ + const sourceRoot = path.join(root, 'src', 'runtime'); + await fs.mkdir(sourceRoot, { recursive: true }); + await fs.writeFile(path.join(sourceRoot, 'helper.ts'), 'export const local: string = "local";\n'); + await fs.writeFile(path.join(sourceRoot, 'main.ts'), [ + 'import { readFile } from "fs/promises";', + 'import { dependencyMessage } from "portable-fixture-dependency";', + 'import { local } from "./helper.ts";', + 'export const value: string = `${dependencyMessage}:${local}:${typeof readFile}`;', + ].join('\n')); + await fs.writeFile(path.join(sourceRoot, 'plain.mts'), 'export const plain: string = "plain";\n'); + /** 可选正常或故障依赖。 */ + const packageRoot = dependency === false ? undefined : await writeDependency(root, dependency); + /** 当前 BuildSession capability registries。 */ + const owner = 'framework:portable'; + const scope = new BuildSessionScope(); + const sources = new SourceRegistry(scope, root); + const work = new WorkDirectoryRegistry(scope, path.join(root, '.work')); + const assets = new AssetRegistry(scope, sources, work); + /** 当前 Session 唯一 Watch Registry。 */ + const watch = new WatchRegistry(scope, root); + const sourceDirectory = await sources.issueRoot(owner, sourceRoot); + const sourceService = sources.service(owner); + const main = await sourceService.file(sourceDirectory, 'main.ts'); + const plain = await sourceService.file(sourceDirectory, 'plain.mts'); + const host = new CompilerHost({ + projectRoot: root, + sources, + workDirectories: work, + assets, + watch, + }); + return { + root, + sourceRoot, + packageRoot, + sourceDirectory, + sourceService, + main, + plain, + assets, + owner, + watch, + service: await host.service(owner), + }; +} + +/** + * 创建固定 portable-node Job。 + * + * @param entries 当前 owner 的命名 SourceRef。 + * @param overrides 需要覆盖的 Job 字段。 + * @returns 可交给 CompilerService 的请求。 + */ +function portableJob( + entries: Readonly>, + overrides: Record = {}, +): CompileJob<'portable-node'> { + return { + id: 'portable-job', + profile: 'portable-node', + entries: Object.fromEntries(Object.entries(entries).map(([id, source]) => [id, { + type: 'source' as const, + source, + mode: id === 'main' ? 0o755 : 0o644, + }])), + ...overrides, + } as CompileJob<'portable-node'>; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('portable-node Compiler Profile', () => { + it('resolves package identity above type-only nested manifests', async () => { + /** 许多 ESM 包在 dist/esm 下放置只含 type 的 package.json。 */ + const current = await fixture(); + /** 模拟真实 SDK 的嵌套 module-format boundary。 */ + const nested = path.join(current.packageRoot!, 'dist/esm'); + await fs.mkdir(nested, { recursive: true }); + await fs.writeFile(path.join(nested, 'package.json'), JSON.stringify({ type: 'module' })); + await fs.writeFile(path.join(nested, 'index.js'), 'export const value = true;\n'); + + await expect(packageScope(path.join(nested, 'index.js'))).resolves.toMatchObject({ + name: 'portable-fixture-dependency', + version: '3.2.1', + root: await fs.realpath(current.packageRoot!), + }); + }); + + it('builds each TS entry as an independent Node 20 ESM bundle with strict licenses', async () => { + /** 当前包含第三方依赖的完整 fixture。 */ + const current = await fixture(); + const result = await current.service.compile(portableJob({ main: current.main, plain: current.plain })); + + expect(result.profile).toBe('portable-node'); + expect(result.outputs.map(output => [output.outputId, output.type, output.fileName])).toEqual([ + ['main', 'chunk', 'main.mjs'], + ['main', 'licenses', 'THIRD_PARTY_LICENSES.txt'], + ['plain', 'chunk', 'main.mjs'], + ]); + /** npm dependency 已内联,只保留规范化 node: builtin。 */ + const main = result.outputs.find(output => output.outputId === 'main' && output.type === 'chunk')!; + const mainCode = new TextDecoder().decode(await current.assets.service(current.owner).read(main.asset)); + expect(mainCode).toContain('dependency-ready'); + expect(mainCode).toContain('from"node:fs/promises"'); + expect(mainCode).not.toContain('portable-fixture-dependency"'); + expect(mainCode).not.toContain(current.root); + /** 第三方 license 只与实际包含依赖的 entry 相邻。 */ + const license = result.outputs.find(output => output.type === 'licenses')!; + const licenseText = new TextDecoder().decode(await current.assets.service(current.owner).read(license.asset)); + expect(licenseText).toContain('Package: portable-fixture-dependency@3.2.1'); + expect(licenseText).toContain('License: MIT'); + expect(licenseText).toContain('--- LICENSE ---'); + expect(licenseText).toContain('--- NOTICE.md ---'); + expect(licenseText).not.toContain(current.root); + /** source/package/manifest/legal inputs 全部进入唯一 watch 出口。 */ + expect(current.watch.snapshot().paths).toEqual(expect.arrayContaining([ + await fs.realpath(path.join(current.sourceRoot, 'main.ts')), + await fs.realpath(path.join(current.sourceRoot, 'helper.ts')), + await fs.realpath(path.join(current.packageRoot!, 'index.js')), + await fs.realpath(path.join(current.packageRoot!, 'package.json')), + await fs.realpath(path.join(current.packageRoot!, 'LICENSE')), + await fs.realpath(path.join(current.packageRoot!, 'NOTICE.md')), + ])); + expect(result.modules.some(module => module.id === 'package:portable-fixture-dependency@3.2.1/index.js')).toBe(true); + }); + + it('produces identical bytes across physical roots and allows package-manager symlinks', async () => { + /** 两个不同临时绝对根,其中一个依赖经 pnpm 风格 symlink 解析。 */ + const direct = await fixture(); + const symlinked = await fixture({ symlink: true }); + const first = await direct.service.compile(portableJob({ main: direct.main })); + const second = await symlinked.service.compile(portableJob({ main: symlinked.main })); + const firstBytes = await direct.assets.service(direct.owner).read(first.outputs.find(output => output.type === 'chunk')!.asset); + const secondBytes = await symlinked.assets.service(symlinked.owner).read(second.outputs.find(output => output.type === 'chunk')!.asset); + expect(firstBytes).toEqual(secondBytes); + const firstLicense = await direct.assets.service(direct.owner).read(first.outputs.find(output => output.type === 'licenses')!.asset); + const secondLicense = await symlinked.assets.service(symlinked.owner).read(second.outputs.find(output => output.type === 'licenses')!.asset); + expect(firstLicense).toEqual(secondLicense); + }); + + it('accepts only the frozen portable JSON option subset', async () => { + /** 不使用第三方依赖的入口可单独验证 option mapping。 */ + const current = await fixture(false); + await expect(current.service.compile(portableJob({ plain: current.plain }, { + options: { + resolve: { extensions: ['.mts', '.ts', '.js'] }, + transform: { define: { PORTABLE_FLAG: '"ready"' }, dropLabels: ['DEBUG'] }, + treeshake: true, + }, + }))).resolves.toMatchObject({ profile: 'portable-node' }); + + await expect(current.service.compile(portableJob({ plain: current.plain }, { + id: 'unknown-option', + options: { plugins: [] }, + }))).rejects.toThrow('plugins is unknown'); + await expect(current.service.compile(portableJob({ plain: current.plain }, { + id: 'unsafe-transform', + options: { transform: { inject: { process: './shim.js' } } }, + }))).rejects.toThrow('inject is unknown'); + await expect(current.service.compile(portableJob({ plain: current.plain }, { + id: 'unsafe-minify', + options: { minify: false }, + }))).rejects.toThrow('minify is unknown'); + }); + + it('rejects unresolved, non-literal, native and implicit runtime imports', async () => { + /** 每个故障用独立 fixture,避免 SourceRef 发放后的文件修改触发更早指纹诊断。 */ + const check = async (code: string, expected: string): Promise => { + const current = await fixture(false); + await fs.writeFile(path.join(current.sourceRoot, 'invalid.ts'), code); + const invalid = await current.sourceService.file(current.sourceDirectory, 'invalid.ts'); + await expect(current.service.compile(portableJob({ invalid }))).rejects.toThrow(expected); + }; + await check('import "missing-package"; export const value = true;', 'residual non-node import'); + await check('const target = "./helper.ts"; export const value = import(target);', 'non-literal dynamic import'); + await check('const target = "./helper.ts"; export const value = require(target);', 'non-literal require'); + await check('export { default } from "./native.node";', 'native addon'); + await check('export const file = new URL("./data.json", import.meta.url);', 'implicit runtime file'); + }); + + it('fails strict license collection for missing or invalid evidence', async () => { + /** package manifest 没有 SPDX field。 */ + const missingSpdx = await fixture({ license: false }); + await expect(missingSpdx.service.compile(portableJob({ main: missingSpdx.main }))).rejects.toThrow('SPDX'); + /** 非法 SPDX expression 不能冒充元数据。 */ + const invalidSpdx = await fixture({ license: 'Definitely Not SPDX' }); + await expect(invalidSpdx.service.compile(portableJob({ main: invalidSpdx.main }))).rejects.toThrow('invalid license SPDX'); + /** 只有 SPDX 字段、没有实际法律正文仍然失败。 */ + const missingLegal = await fixture({ legal: false }); + await expect(missingLegal.service.compile(portableJob({ main: missingLegal.main }))).rejects.toThrow('license or notice evidence'); + }); +}); diff --git a/packages/core/test/compiler/compiler-portable.types.ts b/packages/core/test/compiler/compiler-portable.types.ts new file mode 100644 index 0000000..2e23f6a --- /dev/null +++ b/packages/core/test/compiler/compiler-portable.types.ts @@ -0,0 +1,24 @@ +import { expectTypeOf } from 'vitest'; +import type { + CompileOptions, + PortableNodeCompileOptions, + PortableNodeResolveOptions, +} from '../../src/contracts/index.js'; + +/** portable options 必须只有精确 Profile map 中的一份类型。 */ +expectTypeOf>().toEqualTypeOf(); + +/** readonly 作者数组可以直接复用同一套 portable 参数。 */ +const options = { + resolve: { extensions: ['.ts', '.js'] as const }, + transform: { define: { FEATURE: 'true' }, jsx: false as const }, + treeshake: true, +} satisfies CompileOptions<'portable-node'>; +expectTypeOf(options.resolve).toMatchTypeOf(); + +/** arbitrary Plugin 不属于 portable public surface。 */ +const invalid = { + // @ts-expect-error portable-node does not expose Rolldown Plugins + plugins: [], +} satisfies CompileOptions<'portable-node'>; +void invalid; diff --git a/packages/core/test/compiler/module-host.test.ts b/packages/core/test/compiler/module-host.test.ts new file mode 100644 index 0000000..9424391 --- /dev/null +++ b/packages/core/test/compiler/module-host.test.ts @@ -0,0 +1,188 @@ +import { createRequire } from 'node:module'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; +import { BuildSessionScope } from '../../src/services/session-scope.js'; +import { ModuleHost } from '../../src/compiler/module-host.js'; +import { SourceRegistry } from '../../src/services/sources.js'; +import { WatchRegistry } from '../../src/services/watch.js'; +import { WorkDirectoryRegistry } from '../../src/services/work-directories.js'; + +/** Module Host 测试创建的临时工程根。 */ +const roots: string[] = []; + +/** + * 写入一个 ESM/CJS package dependency。 + * + * @param root 工程根。 + * @param name package 名称。 + * @param format package 模块格式。 + * @param symlink 是否模拟 pnpm store 目录链接。 + * @returns package 真实物理 entry。 + */ +async function dependency(root: string, name: string, format: 'esm' | 'cjs', symlink = false): Promise { + /** symlink package 使用工程内 store,但正常 node_modules 路径是目录链接。 */ + const packageRoot = symlink ? path.join(root, '.store', name) : path.join(root, 'node_modules', name); + await fs.mkdir(packageRoot, { recursive: true }); + /** 当前格式对应的 package entry 文件名。 */ + const entryName = format === 'esm' ? 'index.js' : 'index.cjs'; + await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ + name, + version: '1.2.3', + ...(format === 'esm' ? { type: 'module' } : {}), + exports: `./${entryName}`, + })); + await fs.writeFile(path.join(packageRoot, entryName), format === 'esm' + ? 'export default Object.freeze({ format: "esm" });\n' + : 'module.exports = Object.freeze({ format: "cjs" });\n'); + if (symlink) { + /** node_modules package link 保留正常包管理器解析语义。 */ + const modules = path.join(root, 'node_modules'); + await fs.mkdir(modules, { recursive: true }); + await fs.symlink(packageRoot, path.join(modules, name), 'dir'); + } + return fs.realpath(path.join(packageRoot, entryName)); +} + +/** + * 创建一轮全新的 owner-scoped Module Host Session。 + * + * @param root 工程根。 + * @param sourceRoot 作者模块 root。 + * @param entryRelative 入口相对路径。 + * @param sessionId 独占 work 根后缀。 + * @returns 当前 BuildSession 的 module service 和 watch。 + */ +async function session(root: string, sourceRoot: string, entryRelative: string, sessionId: string) { + /** 每次调用使用新的 capability scope,防止 ESM cache 混入授权语义。 */ + const scope = new BuildSessionScope(); + /** 当前 Session Source Registry。 */ + const sources = new SourceRegistry(scope, root); + /** 当前 Session owner。 */ + const owner = 'framework:config'; + /** config 来源根 ref。 */ + const sourceDirectory = await sources.issueRoot(owner, sourceRoot); + /** config 精确入口 ref。 */ + const entry = await sources.service(owner).file(sourceDirectory, entryRelative); + /** 当前 Session 独占 workDir。 */ + const workDirectories = new WorkDirectoryRegistry(scope, path.join(root, '.work', sessionId)); + /** 当前 Session 唯一 Watch Registry。 */ + const watch = new WatchRegistry(scope, root); + /** 当前 Session 唯一 Module Host。 */ + const host = new ModuleHost({ projectRoot: root, sources, workDirectories, watch }); + return { scope, watch, entry, service: host.service(owner) }; +} + +/** + * 创建包含 local graph、imports map 和 ESM/CJS package 的工程。 + * + * @param symlink ESM dependency 是否使用 package-manager link。 + * @returns 可用于多 Session 的工程 fixture。 + */ +async function fixture(symlink = false) { + /** 当前测试独占工程根。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-module-host-')); + roots.push(root); + /** Module Host 被授权的作者模块根。 */ + const sourceRoot = path.join(root, 'src', 'config'); + await fs.mkdir(sourceRoot, { recursive: true }); + await fs.writeFile(path.join(root, 'package.json'), JSON.stringify({ + name: 'module-host-project', + version: '1.0.0', + type: 'module', + imports: { '#local': './src/config/imported.ts' }, + })); + await fs.writeFile(path.join(sourceRoot, 'helper.ts'), 'export const local: string = "local";\n'); + await fs.writeFile(path.join(sourceRoot, 'imported.ts'), 'export const imported: string = "imports-map";\n'); + /** ESM package 可选使用 pnpm 风格链接。 */ + const esmEntry = await dependency(root, 'esm-fixture', 'esm', symlink); + /** CJS package 验证 Node external interop identity。 */ + const cjsEntry = await dependency(root, 'cjs-fixture', 'cjs'); + await fs.writeFile(path.join(sourceRoot, 'config.ts'), [ + 'import esm from "esm-fixture";', + 'import cjs from "cjs-fixture";', + 'import { local } from "./helper.ts";', + 'import { imported } from "#local";', + 'export default { esm, cjs, local, imported };', + ].join('\n')); + return { root, sourceRoot, esmEntry, cjsEntry }; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('ModuleHost', () => { + it('bundles local TypeScript and preserves ESM/CJS package instance identity', async () => { + /** package manager link 同时覆盖外部真实路径和 watch identity。 */ + const current = await fixture(true); + /** 当前全新 Module Session。 */ + const currentSession = await session(current.root, current.sourceRoot, 'config.ts', 'first'); + const result = await currentSession.service.loadDefault<{ + readonly esm: object; + readonly cjs: object; + readonly local: string; + readonly imported: string; + }>({ id: 'project-config', entry: currentSession.entry }); + /** 直接 Node import 必须观察同一 externalized ESM instance。 */ + const directEsm = await import(pathToFileURL(current.esmEntry).href) as { readonly default: object }; + /** require 与 Module Host 的 CJS default 必须观察同一 Node cache identity。 */ + const directCjs = createRequire(import.meta.url)(current.cjsEntry) as object; + + expect(result.local).toBe('local'); + expect(result.imported).toBe('imports-map'); + expect(result.esm).toBe(directEsm.default); + expect(result.cjs).toBe(directCjs); + expect(currentSession.watch.snapshot().identities).toEqual(expect.arrayContaining([ + 'package.json', + 'src/config/config.ts', + 'src/config/helper.ts', + 'src/config/imported.ts', + 'package:esm-fixture@1.2.3/index.js', + 'package:esm-fixture@1.2.3/package.json', + 'package:cjs-fixture@1.2.3/index.cjs', + 'package:cjs-fixture@1.2.3/package.json', + ])); + }); + + it('freshly evaluates each BuildSession and rejects a missing default export', async () => { + /** 当前测试独占工程。 */ + const current = await fixture(); + await fs.writeFile(path.join(current.sourceRoot, 'fresh.ts'), 'export default { value: 1 };\n'); + /** 第一轮独立 Session 读取旧值。 */ + const first = await session(current.root, current.sourceRoot, 'fresh.ts', 'fresh-one'); + await expect(first.service.loadDefault<{ readonly value: number }>({ id: 'fresh-config', entry: first.entry })).resolves.toEqual({ value: 1 }); + first.scope.close(); + await fs.writeFile(path.join(current.sourceRoot, 'fresh.ts'), 'export default { value: 2 };\n'); + /** 新 SourceRef + work URL 必须绕开上一轮 ESM cache。 */ + const second = await session(current.root, current.sourceRoot, 'fresh.ts', 'fresh-two'); + await expect(second.service.loadDefault<{ readonly value: number }>({ id: 'fresh-config', entry: second.entry })).resolves.toEqual({ value: 2 }); + + await fs.writeFile(path.join(current.sourceRoot, 'missing-default.ts'), 'export const value = 1;\n'); + /** 缺少 default export 是稳定的 Module contract failure。 */ + const missing = await session(current.root, current.sourceRoot, 'missing-default.ts', 'missing'); + await expect(missing.service.loadDefault({ id: 'missing-default', entry: missing.entry })).rejects.toThrow('default export'); + }); + + it('rejects local source escape, non-literal dynamic import and duplicate operations', async () => { + /** 当前测试独占工程。 */ + const current = await fixture(); + await fs.writeFile(path.join(current.root, 'outside.ts'), 'export default "outside";\n'); + await fs.writeFile(path.join(current.sourceRoot, 'escape.ts'), 'export { default } from "../../outside.ts";\n'); + await fs.writeFile(path.join(current.sourceRoot, 'dynamic.ts'), 'const target = "./helper.ts"; export default import(target);\n'); + + /** local graph 不得越过当前 Source root。 */ + const escaped = await session(current.root, current.sourceRoot, 'escape.ts', 'escape'); + await expect(escaped.service.loadDefault({ id: 'escape-config', entry: escaped.entry })).rejects.toThrow('escaped'); + /** 无法静态登记的动态 import 不得留到 runtime。 */ + const dynamic = await session(current.root, current.sourceRoot, 'dynamic.ts', 'dynamic'); + await expect(dynamic.service.loadDefault({ id: 'dynamic-config', entry: dynamic.entry })).rejects.toThrow('non-literal dynamic import'); + + /** owner 内 operation ID 只能消费一次,避免覆盖 work output/watch identity。 */ + const duplicate = await session(current.root, current.sourceRoot, 'config.ts', 'duplicate'); + await duplicate.service.loadDefault({ id: 'same-operation', entry: duplicate.entry }); + await expect(duplicate.service.loadDefault({ id: 'same-operation', entry: duplicate.entry })).rejects.toThrow('already used'); + }); +}); diff --git a/packages/core/test/config/config-resolver.test.ts b/packages/core/test/config/config-resolver.test.ts new file mode 100644 index 0000000..57cec03 --- /dev/null +++ b/packages/core/test/config/config-resolver.test.ts @@ -0,0 +1,152 @@ +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { defineExtension, definePlatform } from '../../src/api/definitions.js'; +import { resolveKernelConfig } from '../../src/config/resolver.js'; + +/** @returns 配置测试使用的最小 Platform。 */ +function platform(id: string, strict?: boolean) { + return definePlatform({ + id, + apiVersion: '1', + deliveryType: 'plugin', + ...(strict === undefined ? {} : { strict }), + createSession: () => ({ + createPackage: () => ({ documents: [], assets: [], compatibility: [], metadata: [] }), + finalizePackage: () => ({ id: 'plugin', type: 'plugin' }), + validatePackage: () => undefined, + }), + }); +} + +/** @returns 配置测试使用的最小 Extension。 */ +function extension(id: string, roots: readonly string[]) { + return defineExtension({ + id, + apiVersion: '1', + resourceRoots: roots, + createSession: () => ({ + discover: () => ({}), + validate: () => ({ state: {}, subjects: [] }), + build: () => ({ state: {} }), + contributors: [], + }), + }); +} + +/** 固定工程根的 config resolver。 */ +function resolve(value: unknown) { + return resolveKernelConfig(value, { + projectRoot: '/project', + configFile: '/project/acplugin.config.ts', + command: 'build', + mode: 'production', + }); +} + +describe('Kernel config resolver', () => { + it('normalizes metadata, Runtime, integrations and strictness as immutable data', () => { + /** Platform override 与全局 strict 共同验证最终行为。 */ + const primary = platform('primary'); + const relaxed = platform('relaxed', false); + const hooks = extension('hooks', ['hooks']); + const result = resolve({ + name: 'release-tools', + version: '1.2.3', + description: ' Release tools. ', + author: { name: 'TokenRoll', email: 'team@example.com' }, + keywords: ['release', 'review'], + runtime: { + entries: { cli: { entry: 'bin/cli.ts', kind: 'module' } }, + compile: { treeshake: false, transform: { define: { FLAG: 'true' } } }, + }, + platforms: [primary, relaxed], + extensions: [hooks], + build: { strict: true }, + }); + + expect(result.diagnostics).toEqual([]); + expect(result.config).toMatchObject({ + projectRoot: '/project', + srcDirectory: path.join('/project', 'src'), + metadata: { name: 'release-tools', version: '1.2.3', description: 'Release tools.' }, + runtime: { target: 'node20', entries: { cli: { entry: 'bin/cli.ts', kind: 'module' } } }, + strict: true, + }); + expect(result.config?.platforms.map(item => [item.definition.id, item.strict])).toEqual([['primary', true], ['relaxed', false]]); + expect(Object.isFrozen(result.config)).toBe(true); + expect(Object.isFrozen(result.config?.runtime.compile)).toBe(true); + }); + + it('allows project-root Public only through per-source exact non-overlapping copy rules', () => { + const ok = resolve({ + name: 'public-copy', version: '1.0.0', description: 'Public copy.', platforms: [platform('target')], + public: { dir: '.', copy: [{ from: 'schemas', to: 'schemas' }, { from: 'rulepacks', to: 'runtime/rulepacks' }] }, + }); + const overlap = resolve({ + name: 'public-overlap', version: '1.0.0', description: 'Public overlap.', platforms: [platform('target')], + public: { dir: '.', copy: [{ from: 'src', to: 'source' }] }, + }); + const full = resolve({ + name: 'public-full', version: '1.0.0', description: 'Public full.', platforms: [platform('target')], + public: { dir: '.' }, + }); + + expect(ok.diagnostics).toEqual([]); + expect(ok.config?.public.copy?.map(rule => [rule.from, rule.to])).toEqual([ + ['schemas', 'schemas'], + ['rulepacks', 'runtime/rulepacks'], + ]); + expect(overlap.diagnostics).toContainEqual(expect.objectContaining({ code: 'CONFIG_PUBLIC_OVERLAP' })); + expect(full.diagnostics).toContainEqual(expect.objectContaining({ code: 'CONFIG_PUBLIC_OVERLAP' })); + }); + + it('rejects getters, class instances, unknown fields, unsafe paths and duplicate integration IDs', () => { + /** getter 不得在配置检查期间被执行。 */ + let getterRead = false; + const withGetter = Object.defineProperty({ + name: 'getter', version: '1.0.0', description: 'Getter.', platforms: [platform('target')], + }, 'srcDir', { + enumerable: true, + get: () => { + getterRead = true; + return 'src'; + }, + }); + /** class instance 不属于纯配置。 */ + class Config {} + const duplicate = platform('duplicate'); + const invalid = resolve({ + name: 'invalid', version: '1.0.0', description: 'Invalid.', + srcDir: '../outside', + unknown: true, + platforms: [duplicate, duplicate], + }); + + expect(resolve(withGetter).diagnostics).toContainEqual(expect.objectContaining({ code: 'CONFIG_ACCESSOR_INVALID' })); + expect(getterRead).toBe(false); + expect(resolve(new Config()).diagnostics).toContainEqual(expect.objectContaining({ code: 'CONFIG_OBJECT_INVALID' })); + expect(invalid.diagnostics.map(item => item.code)).toEqual(expect.arrayContaining([ + 'CONFIG_FIELD_UNKNOWN', 'CONFIG_PATH_INVALID', 'CONFIG_PLATFORM_DUPLICATE', + ])); + }); + + it('aggregates independent metadata and structural failures in one deterministic result', () => { + const result = resolve({ + name: 'Invalid Name', + version: 'invalid', + description: '', + author: { name: '', email: 'invalid', url: 'file:///secret' }, + keywords: ['duplicate', ' duplicate ', ''], + srcDir: '../outside', + platforms: [], + }); + + expect(result.config).toBeUndefined(); + expect(result.diagnostics.map(item => item.code)).toEqual(expect.arrayContaining([ + 'CONFIG_NAME_INVALID', 'CONFIG_VERSION_INVALID', 'CONFIG_DESCRIPTION_REQUIRED', + 'CONFIG_AUTHOR_NAME_INVALID', 'CONFIG_AUTHOR_EMAIL_INVALID', 'CONFIG_AUTHOR_URL_INVALID', + 'CONFIG_KEYWORD_DUPLICATE', 'CONFIG_KEYWORD_INVALID', 'CONFIG_PATH_INVALID', + 'CONFIG_PLATFORMS_REQUIRED', + ])); + }); +}); diff --git a/packages/core/test/contracts/integration-definitions.test.ts b/packages/core/test/contracts/integration-definitions.test.ts new file mode 100644 index 0000000..8172fd0 --- /dev/null +++ b/packages/core/test/contracts/integration-definitions.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from 'vitest'; +import { + defineExtension, + definePlatform, + isAcpluginExtension, + isAcpluginPlatform, + LIFECYCLE_API_VERSION, +} from '../../src/api/integration.js'; + +/** + * 创建 Integration definition 契约测试使用的最小 Platform。 + * + * @param overrides 需要覆盖的定义字段。 + * @returns 交给 definePlatform 的完整定义。 + */ +function platformDefinition(overrides: Record = {}) { + return { + id: 'third-party', + apiVersion: '1', + deliveryType: 'plugin', + /** 每个 BuildSession 返回独立生命周期对象。 */ + createSession: () => ({ + /** 最小 Platform 产生空 base Package。 */ + createPackage: () => ({ documents: [], assets: [], compatibility: [], metadata: [] }), + /** 最小 Platform 确定一个 Plugin 主单元。 */ + finalizePackage: () => ({ id: 'plugin', type: 'plugin' }), + /** 最小 Platform 没有额外 candidate 约束。 */ + validatePackage: () => undefined, + }), + ...overrides, + }; +} + +/** + * 创建 Integration definition 契约测试使用的最小 Extension。 + * + * @param overrides 需要覆盖的定义字段。 + * @returns 交给 defineExtension 的完整定义。 + */ +function extensionDefinition(overrides: Record = {}) { + return { + id: 'third-party-extension', + apiVersion: '1', + resourceRoots: ['third-party'], + /** 每个 BuildSession 返回独立 Extension 生命周期对象。 */ + createSession: () => ({ + /** 空资源使后续阶段可以跳过。 */ + discover: () => undefined, + /** 最小验证输出没有 compatibility subject。 */ + validate: () => ({ state: {}, subjects: [] }), + /** 最小 build 输出为空状态。 */ + build: () => ({ state: {} }), + contributors: [], + }), + ...overrides, + }; +} + +describe('Integration definition contract', () => { + it('keeps API version one while branding and freezing complete Platform definitions', () => { + /** 调用方仍持有并将在工厂返回后修改的 options。 */ + const options = { marketplace: { states: ['AVAILABLE'] } }; + /** 共享工厂生成的最终 Platform。 */ + const platform = definePlatform({ + ...platformDefinition(), + options, + capabilities: { nodeRuntime: { target: 'node20', format: 'esm', root: 'plugin' } }, + } as never); + + options.marketplace.states.push('PRIVATE'); + expect(LIFECYCLE_API_VERSION).toBe('1'); + expect(isAcpluginPlatform(platform)).toBe(true); + expect(Object.isFrozen(platform)).toBe(true); + expect(Object.isFrozen(platform.options)).toBe(true); + expect(Object.isFrozen(platform.options!.marketplace)).toBe(true); + expect(platform.options).toEqual({ marketplace: { states: ['AVAILABLE'] } }); + }); + + it('rejects unknown fields, accessors, classes, cycles, sparse arrays and non-finite options', () => { + /** 循环 JSON 不能被复制成稳定 options。 */ + const cycle: Record = {}; + cycle.self = cycle; + /** 稀疏数组不能借助 JSON stringify 隐式变为 null。 */ + const sparse = new Array(2); + sparse[1] = 'value'; + /** class instance 不能把 prototype 行为藏入配置。 */ + class Options {} + + expect(() => definePlatform(platformDefinition({ unknown: true }) as never)).toThrow('Unknown Platform definition field'); + expect(() => definePlatform(Object.defineProperty(platformDefinition(), 'options', { + /** accessor 用于验证工厂不会执行不可信 getter。 */ + get: () => ({}), + enumerable: true, + }) as never)).toThrow('accessor'); + expect(() => definePlatform(platformDefinition({ options: new Options() }) as never)).toThrow('plain object'); + expect(() => definePlatform(platformDefinition({ options: cycle }) as never)).toThrow('cycles'); + expect(() => definePlatform(platformDefinition({ options: { sparse } }) as never)).toThrow('sparse'); + expect(() => definePlatform(platformDefinition({ options: { invalid: Number.POSITIVE_INFINITY } }) as never)).toThrow('finite'); + }); + + it('rejects wrong API versions, invalid identities and shape-compatible forgeries', () => { + /** 没有工厂品牌的完整等形对象。 */ + const forged = Object.freeze(platformDefinition()); + + expect(isAcpluginPlatform(forged)).toBe(false); + expect(() => definePlatform(platformDefinition({ apiVersion: '2' }) as never)).toThrow('Unsupported Platform API version'); + expect(() => definePlatform(platformDefinition({ id: 'Third Party' }) as never)).toThrow('lowercase kebab-case'); + expect(() => definePlatform(platformDefinition({ capabilities: { nodeRuntime: { target: 'node18' } } }) as never)).toThrow('Node 20 ESM'); + }); + + it('rejects discovered brand copies whose public shape or deep freeze was forged', () => { + /** 有效对象用于证明 copied brand 仍不能替代完整 shape validation。 */ + const platform = definePlatform({ ...platformDefinition(), options: { nested: { enabled: true } } } as never); + /** 从合法对象复制到可修改等形对象的全部自有描述符。 */ + const descriptors = Object.getOwnPropertyDescriptors(platform); + /** forged 删除 createSession 后重新冻结,仍保留发现到的 Symbol 品牌。 */ + const missingSession = {}; + Object.defineProperties(missingSession, Object.fromEntries(Reflect.ownKeys(descriptors) + .filter(key => key !== 'createSession') + .map(key => [key, Reflect.get(descriptors, key)]))); + Object.freeze(missingSession); + /** shallowFrozen 复制完整 shape,但替换为内部未冻结的 options。 */ + const shallowFrozen = {}; + Object.defineProperties(shallowFrozen, Object.fromEntries(Reflect.ownKeys(descriptors) + .filter(key => key !== 'options') + .map(key => [key, Reflect.get(descriptors, key)]))); + Object.defineProperty(shallowFrozen, 'options', { + value: Object.freeze({ nested: { enabled: true } }), + enumerable: true, + configurable: false, + writable: false, + }); + Object.freeze(shallowFrozen); + + expect(isAcpluginPlatform(missingSession)).toBe(false); + expect(isAcpluginPlatform(shallowFrozen)).toBe(false); + }); + + it('normalizes Extension id, roots and options without lifecycle ordering fields', () => { + /** 调用方仍持有的 resource root 与 options 容器。 */ + const resourceRoots = ['third-party']; + /** 调用方仍持有的 Extension options。 */ + const options = { include: ['alpha'] }; + /** 共享工厂生成的最终 Extension。 */ + const extension = defineExtension({ ...extensionDefinition(), resourceRoots, options } as never); + + resourceRoots.push('other'); + options.include.push('beta'); + expect(isAcpluginExtension(extension)).toBe(true); + expect(extension.id).toBe('third-party-extension'); + expect(extension.resourceRoots).toEqual(['third-party']); + expect(extension.options).toEqual({ include: ['alpha'] }); + expect('name' in extension).toBe(false); + expect('dependsOn' in extension).toBe(false); + }); + + it('rejects duplicate roots, unknown fields, wrong versions and Extension forgeries', () => { + /** 没有工厂品牌的完整等形 Extension。 */ + const forged = Object.freeze(extensionDefinition()); + + expect(isAcpluginExtension(forged)).toBe(false); + expect(() => defineExtension(extensionDefinition({ apiVersion: '2' }) as never)).toThrow('Unsupported Extension API version'); + expect(() => defineExtension(extensionDefinition({ resourceRoots: ['hooks', 'hooks'] }) as never)).toThrow('duplicates'); + expect(() => defineExtension(extensionDefinition({ resourceRoots: ['nested/root'] }) as never)).toThrow('lowercase kebab-case'); + expect(() => defineExtension(extensionDefinition({ dependsOn: ['other'] }) as never)).toThrow('Unknown Extension definition field'); + }); +}); diff --git a/packages/core/test/contracts/json-snapshot.test.ts b/packages/core/test/contracts/json-snapshot.test.ts new file mode 100644 index 0000000..c83dd5d --- /dev/null +++ b/packages/core/test/contracts/json-snapshot.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { snapshotJson } from '../../src/api/integration.js'; + +describe('strict JSON snapshot boundary', () => { + it('copies, orders and deeply freezes plain JSON without retaining input identity', () => { + /** 调用方仍持有且将在 snapshot 后修改的输入。 */ + const input = { zebra: [{ enabled: true }], alpha: 1 }; + /** SDK 返回的隔离、稳定 snapshot。 */ + const snapshot = snapshotJson(input, 'Fixture'); + + input.zebra[0]!.enabled = false; + expect(snapshot).toEqual({ alpha: 1, zebra: [{ enabled: true }] }); + expect(Object.keys(snapshot as object)).toEqual(['alpha', 'zebra']); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(Object.isFrozen((snapshot as { readonly zebra: readonly unknown[] }).zebra)).toBe(true); + expect(Object.isFrozen((snapshot as { readonly zebra: readonly object[] }).zebra[0])).toBe(true); + }); + + it('handles prototype-sensitive JSON keys without mutating the output prototype', () => { + /** defineProperty 创建合法 JSON data property,避免对象字面量的 __proto__ 特殊语法。 */ + const input: Record = { constructor: 'safe' }; + Object.defineProperty(input, '__proto__', { + value: { polluted: true }, + enumerable: true, + configurable: true, + writable: true, + }); + /** 普通对象输出必须把 __proto__ 保留为自有 data property。 */ + const snapshot = snapshotJson(input, 'Fixture') as Record; + + expect(Object.getPrototypeOf(snapshot)).toBe(Object.prototype); + expect(Object.hasOwn(snapshot, '__proto__')).toBe(true); + expect(snapshot.__proto__).toEqual({ polluted: true }); + expect((Object.prototype as Record).polluted).toBeUndefined(); + }); + + it('rejects executable, hidden and structurally ambiguous values without invoking getters', () => { + /** getter 调用次数证明边界只读取 descriptor。 */ + let getterCalls = 0; + /** accessor object 不得在诊断过程中执行 getter。 */ + const accessor = Object.defineProperty({}, 'secret', { + get: () => { + getterCalls += 1; + return 'value'; + }, + enumerable: true, + }); + /** non-enumerable 字段不能成为 JSON 中的隐藏语义。 */ + const hidden = Object.defineProperty({}, 'hidden', { value: true, enumerable: false }); + /** Symbol 字段不能绕过字符串字段快照。 */ + const symbol = Object.defineProperty({}, Symbol('hidden'), { value: true }); + /** 稀疏数组不能被隐式规范化成 null。 */ + const sparse = new Array(2); + sparse[1] = 'value'; + /** 自定义 Array prototype 不属于无行为 JSON 容器。 */ + const inheritedArray: unknown[] = []; + Object.setPrototypeOf(inheritedArray, Object.create(Array.prototype)); + + expect(() => snapshotJson(accessor, 'Fixture')).toThrow('enumerable data property'); + expect(getterCalls).toBe(0); + expect(() => snapshotJson(hidden, 'Fixture')).toThrow('enumerable data property'); + expect(() => snapshotJson(symbol, 'Fixture')).toThrow('Symbol'); + expect(() => snapshotJson(sparse, 'Fixture')).toThrow('sparse'); + expect(() => snapshotJson(inheritedArray, 'Fixture')).toThrow('plain array'); + }); + + it('rejects cycles, unsupported primitives and non-finite numbers with stable paths', () => { + /** 自引用对象验证 ancestor-based cycle detection。 */ + const cycle: Record = {}; + cycle.self = cycle; + + expect(() => snapshotJson(cycle, 'Fixture')).toThrow('Fixture.self must not contain cycles'); + expect(() => snapshotJson({ nested: undefined }, 'Fixture')).toThrow('Fixture.nested must contain only JSON values'); + expect(() => snapshotJson({ nested: 1n }, 'Fixture')).toThrow('Fixture.nested must contain only JSON values'); + expect(() => snapshotJson({ nested: Number.NaN }, 'Fixture')).toThrow('Fixture.nested must contain only finite JSON numbers'); + expect(() => snapshotJson({}, '')).toThrow('label must be a non-empty string'); + }); +}); diff --git a/packages/core/test/lifecycle/build-session.test.ts b/packages/core/test/lifecycle/build-session.test.ts new file mode 100644 index 0000000..b56a014 --- /dev/null +++ b/packages/core/test/lifecycle/build-session.test.ts @@ -0,0 +1,995 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { + BytesAssetRef, + FinalizationAssetService, + JsonObject, + SourceAssetRef, + SourceFileRef, +} from '../../src/contracts/index.js'; +import { defineExtension, definePlatform } from '../../src/api/definitions.js'; +import { + runKernelBuildSession, +} from '../../src/lifecycle/build-session.js'; +import { + createKernelBuildEnvironment, + disposeKernelBuildEnvironment, +} from '../../src/lifecycle/build-environment.js'; +import { resolveKernelConfig } from '../../src/config/resolver.js'; + +/** 当前套件创建并统一删除的临时工程。 */ +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +/** 创建包含 canonical/public/Extension resource 的最小工程。 */ +async function fixture() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-build-session-')); + roots.push(root); + await fs.mkdir(path.join(root, 'src', 'commands'), { recursive: true }); + await fs.mkdir(path.join(root, 'src', 'addons'), { recursive: true }); + await fs.mkdir(path.join(root, 'public'), { recursive: true }); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), 'export default {}\n'); + await fs.writeFile(path.join(root, 'src', 'commands', 'review.md'), [ + '---', + 'description: Review changes.', + '---', + 'Review the current changes.', + '', + ].join('\n')); + await fs.writeFile(path.join(root, 'src', 'addons', 'notice.txt'), 'extension notice\n'); + await fs.writeFile(path.join(root, 'public', 'README.txt'), 'public readme\n'); + return root; +} + +/** Platform 必须对当前 metadata 和 Component 提供完整兼容结论。 */ +function metadata() { + return ['name', 'version', 'description'].map(field => ({ + field, + disposition: 'emitted' as const, + output: `manifest/${field}`, + reason: 'The field is emitted by the conformance package.', + })); +} + +describe('Kernel BuildSession', () => { + it('runs the fixed package pipeline, contributes Resources and closes in reverse setup order', async () => { + const root = await fixture(); + /** 调用序列验证 setup、Resource、Package、candidate 和 cleanup 顺序。 */ + const events: string[] = []; + /** validate hook 对每个 canonical Component 只允许调用一次。 */ + const validated: string[] = []; + const platform = definePlatform({ + id: 'conformance', + apiVersion: '1', + deliveryType: 'plugin', + createSession() { + events.push('setup:platform'); + return { + validateComponent({ component }) { + events.push(`validate:${component.kind}:${component.id}`); + validated.push(`${component.kind}:${component.id}`); + }, + createPackage({ project, assets }) { + events.push('package:create'); + return assets.fromBytes({ bytes: 'base\n', origin: { operation: 'base-package' } }).then(asset => ({ + documents: [], + assets: [{ path: 'base.txt', asset }], + compatibility: project.commands.map(command => ({ + subject: `command:${command.id}`, + capability: 'component', + level: 'native' as const, + reason: 'The command is delivered natively.', + })), + metadata: metadata(), + })); + }, + finalizePackage() { + events.push('package:finalize'); + return { id: 'plugin', type: 'plugin' as const }; + }, + async validatePackage({ candidate }) { + events.push(`package:validate:${candidate.unit.role}`); + await expect(fs.readFile(path.join(candidate.root, 'base.txt'), 'utf8')).resolves.toBe('base\n'); + await expect(fs.readFile(path.join(candidate.root, 'README.txt'), 'utf8')).resolves.toBe('public readme\n'); + await expect(fs.readFile(path.join(candidate.root, 'extension', 'notice.txt'), 'utf8')).resolves.toBe('extension notice\n'); + }, + close({ outcome, committed }) { + events.push(`close:platform:${outcome}:${committed}`); + }, + }; + }, + }); + const extension = defineExtension, { readonly file: SourceFileRef }, { readonly file: SourceFileRef }, { readonly asset: SourceAssetRef }>({ + id: 'conformance-extension', + apiVersion: '1', + options: {}, + resourceRoots: ['addons'], + createSession() { + events.push('setup:extension'); + return { + async discover({ roots, sources }) { + events.push('extension:discover'); + const root = roots.addons; + if (root === undefined) + return undefined; + return { file: await sources.file(root, 'notice.txt') }; + }, + validate(_context, discovered) { + events.push('extension:validate'); + return { state: discovered, subjects: [{ subject: 'addon:notice', capabilities: ['delivery'] }] }; + }, + async build({ assets }, validatedState) { + events.push('extension:build'); + return { state: { asset: await assets.fromSource(validatedState.file) } }; + }, + contributors: [{ + platform: 'conformance', + platformApiVersion: '1', + async contribute({ assets: _assets }, built) { + events.push('extension:contribute'); + return { + assets: [{ path: 'extension/notice.txt', asset: built.asset }], + compatibility: [{ + subject: 'addon:notice', + capability: 'delivery', + level: 'native', + reason: 'The addon is delivered natively.', + }], + }; + }, + }], + close({ outcome, committed }) { + events.push(`close:extension:${outcome}:${committed}`); + }, + }; + }, + }); + const resolved = resolveKernelConfig({ + name: 'conformance-plugin', + version: '1.0.0', + description: 'BuildSession conformance.', + platforms: [platform], + extensions: [extension], + }, { + projectRoot: root, + configFile: path.join(root, 'acplugin.config.ts'), + command: 'build', + mode: 'production', + }); + expect(resolved.diagnostics).toEqual([]); + const result = await runKernelBuildSession({ + config: resolved.config!, + frameworkVersion: 'test', + commit: false, + }); + + expect(result.report.success, JSON.stringify(result.report.diagnostics, null, 2)).toBe(true); + expect(result.report.committed).toBe(false); + expect(validated).toEqual(['command:review']); + expect(result.report.packages).toHaveLength(1); + expect(result.report.packages[0]?.assets.map(asset => [asset.path, asset.owner])).toEqual([ + ['README.txt', 'framework:public'], + ['base.txt', 'platform:conformance'], + ['extension/notice.txt', 'extension:conformance-extension'], + ]); + expect(events).toEqual([ + 'setup:platform', + 'setup:extension', + 'extension:discover', + 'validate:command:review', + 'extension:validate', + 'extension:build', + 'package:create', + 'extension:contribute', + 'package:finalize', + 'package:validate:primary', + 'close:extension:success:false', + 'close:platform:success:false', + ]); + }); + + it('revokes a leaked finalization AssetService and enforces Component subjects through the lifecycle', async () => { + const root = await fixture(); + await fs.rm(path.join(root, 'src', 'addons'), { recursive: true }); + await fs.rm(path.join(root, 'public'), { recursive: true }); + let leaked: FinalizationAssetService | undefined; + const platform = definePlatform, JsonObject>({ + id: 'finalization-scope', + apiVersion: '1', + deliveryType: 'plugin', + createSession: () => ({ + createPackage: ({ project }) => ({ + documents: [], + assets: [], + compatibility: project.commands.map(command => ({ + subject: `command:${command.id}`, + capability: 'component', + level: 'native' as const, + reason: 'Native command.', + })), + metadata: metadata(), + }), + async finalizePackage({ package: unit, assets }) { + leaked = assets; + await assets.fromBytes({ + bytes: 'invalid', + origin: { + operation: 'component-probe', + subjects: ['fixture:other'], + componentOrigins: [unit.components[0]!.origin], + }, + }); + return { id: 'plugin', type: 'plugin' as const }; + }, + validatePackage: () => undefined, + }), + }); + const extension = defineExtension({ + id: 'finalization-probe', + apiVersion: '1', + resourceRoots: [], + createSession: () => ({ + discover: () => ({}), + validate: (_context, state) => ({ + state, + subjects: [{ subject: 'fixture:component', capabilities: ['delivery'] }], + }), + build: (_context, state) => ({ state }), + contributors: [{ + platform: 'finalization-scope', + platformApiVersion: '1', + contribute: () => ({ + components: [{ subject: 'fixture:component', value: { kind: 'probe' } }], + compatibility: [{ + subject: 'fixture:component', + capability: 'delivery', + level: 'native' as const, + reason: 'Native fixture.', + }], + }), + }], + }), + }); + const resolved = resolveKernelConfig({ + name: 'finalization-scope', + version: '1.0.0', + description: 'Finalization scope fixture.', + platforms: [platform], + extensions: [extension], + public: false, + }, { + projectRoot: root, + configFile: path.join(root, 'acplugin.config.ts'), + command: 'inspect', + mode: 'production', + }); + const environment = await createKernelBuildEnvironment(root); + try { + const result = await runKernelBuildSession({ + config: resolved.config!, + frameworkVersion: 'test', + commit: false, + environment, + }); + + expect(result.report.success).toBe(false); + expect(result.report.diagnostics).toContainEqual(expect.objectContaining({ + code: 'PLATFORM_FINALIZE_PACKAGE_FAILED', + phase: 'finalize', + platform: 'finalization-scope', + })); + await expect(leaked!.fromBytes({ + bytes: 'late', + origin: { operation: 'late' }, + })).rejects.toThrow('no longer active'); + } finally { + await disposeKernelBuildEnvironment(environment); + } + }); + + it('places reverse close in the rollback window and reports cleanup failure without committing', async () => { + const root = await fixture(); + await fs.mkdir(path.join(root, 'dist', 'conformance', 'old'), { recursive: true }); + await fs.writeFile(path.join(root, 'dist', 'conformance', 'old', 'stable.txt'), 'old\n'); + const platform = definePlatform({ + id: 'conformance', apiVersion: '1', deliveryType: 'plugin', + createSession: () => ({ + createPackage: ({ project, assets }) => assets.fromBytes({ bytes: 'new\n', origin: { operation: 'new-package' } }).then(asset => ({ + documents: [], assets: [{ path: 'new.txt', asset }], + compatibility: project.commands.map(command => ({ + subject: `command:${command.id}`, capability: 'component', level: 'native' as const, reason: 'Native command.', + })), + metadata: metadata(), + })), + finalizePackage: () => ({ id: 'plugin', type: 'plugin' as const }), + validatePackage: () => undefined, + close: () => { throw new Error('secret=/private/build-machine'); }, + }), + }); + const resolved = resolveKernelConfig({ + name: 'rollback-plugin', version: '1.0.0', description: 'Rollback.', platforms: [platform], public: false, + }, { + projectRoot: root, configFile: path.join(root, 'acplugin.config.ts'), command: 'build', mode: 'production', + }); + const result = await runKernelBuildSession({ config: resolved.config!, frameworkVersion: 'test', commit: true }); + + expect(result.report.success).toBe(false); + expect(result.report.committed).toBe(false); + expect(result.report.diagnostics).toContainEqual(expect.objectContaining({ code: 'PLATFORM_CLOSE_FAILED', phase: 'cleanup' })); + expect(result.report.diagnostics.map(item => item.message).join('\n')).not.toContain('/private/build-machine'); + await expect(fs.readFile(path.join(root, 'dist', 'conformance', 'old', 'stable.txt'), 'utf8')).resolves.toBe('old\n'); + await expect(fs.access(path.join(root, 'dist', 'conformance', 'plugin', 'new.txt'))).rejects.toThrow(); + }); + + it('continues independent setup and closes every initialized session once in reverse order', async () => { + const root = await fixture(); + /** 部分 setup 失败前后发生的调用必须保持固定配置顺序。 */ + const events: string[] = []; + const healthy = definePlatform({ + id: 'healthy', apiVersion: '1', deliveryType: 'plugin', + createSession: () => { + events.push('setup:healthy'); + return { + createPackage: ({ project }) => ({ + documents: [], assets: [], + compatibility: project.commands.map(command => ({ + subject: `command:${command.id}`, capability: 'component', level: 'native' as const, reason: 'Native command.', + })), + metadata: metadata(), + }), + finalizePackage: () => ({ id: 'plugin', type: 'plugin' as const }), + validatePackage: () => undefined, + close: () => { events.push('close:healthy'); }, + }; + }, + }); + const broken = definePlatform({ + id: 'broken', apiVersion: '1', deliveryType: 'plugin', + createSession: () => { + events.push('setup:broken'); + throw new Error('setup secret'); + }, + }); + const extension = defineExtension({ + id: 'cleanup-probe', apiVersion: '1', resourceRoots: ['addons'], + createSession: () => { + events.push('setup:extension'); + return { + discover: () => undefined, + validate: (_context, state) => ({ state, subjects: [] }), + build: (_context, state) => ({ state }), + contributors: [], + close: () => { events.push('close:extension'); }, + }; + }, + }); + const resolved = resolveKernelConfig({ + name: 'partial-setup', version: '1.0.0', description: 'Partial setup.', + platforms: [healthy, broken], extensions: [extension], public: false, + }, { + projectRoot: root, configFile: path.join(root, 'acplugin.config.ts'), command: 'validate', mode: 'production', + }); + const result = await runKernelBuildSession({ config: resolved.config!, frameworkVersion: 'test', commit: false }); + + expect(result.report.success).toBe(false); + expect(result.report.packages).toContainEqual(expect.objectContaining({ platform: 'healthy', validated: true })); + expect(result.report.diagnostics).toContainEqual(expect.objectContaining({ code: 'PLATFORM_SETUP_FAILED', platform: 'broken' })); + expect(events).toEqual([ + 'setup:healthy', + 'setup:broken', + 'setup:extension', + 'close:extension', + 'close:healthy', + ]); + }); + + it('preserves the primary package failure in close context when cleanup also fails', async () => { + const root = await fixture(); + await fs.rm(path.join(root, 'src', 'addons'), { recursive: true }); + /** close 只能观察脱敏后的首个业务失败,而不能收到原始异常。 */ + let closeFailure: { readonly code: string; readonly phase: string; readonly message: string } | undefined; + const platform = definePlatform({ + id: 'failed-package', apiVersion: '1', deliveryType: 'plugin', + createSession: () => ({ + createPackage: () => { throw new Error('token=package-secret'); }, + finalizePackage: () => ({ id: 'plugin', type: 'plugin' as const }), + validatePackage: () => undefined, + close: (context) => { + closeFailure = context.failure; + throw new Error('token=cleanup-secret'); + }, + }), + }); + const resolved = resolveKernelConfig({ + name: 'failure-precedence', version: '1.0.0', description: 'Failure precedence.', platforms: [platform], public: false, + }, { + projectRoot: root, configFile: path.join(root, 'acplugin.config.ts'), command: 'build', mode: 'production', + }); + const result = await runKernelBuildSession({ config: resolved.config!, frameworkVersion: 'test', commit: false }); + + expect(closeFailure).toEqual({ + code: 'PLATFORM_CREATE_PACKAGE_FAILED', + phase: 'package', + message: 'Platform "failed-package" createPackage failed.', + }); + expect(result.report.diagnostics.map(item => item.code)).toEqual(expect.arrayContaining([ + 'PLATFORM_CREATE_PACKAGE_FAILED', 'PLATFORM_CLOSE_FAILED', + ])); + expect(JSON.stringify(result.report)).not.toContain('package-secret'); + expect(JSON.stringify(result.report)).not.toContain('cleanup-secret'); + }); + + it('keeps successful Platform packages inspectable when another Platform fails concurrently', async () => { + const root = await fixture(); + await fs.rm(path.join(root, 'src', 'addons'), { recursive: true }); + /** 不同延迟组合用于证明完成顺序不影响稳定报告。 */ + const execute = async (healthyDelay: number, brokenDelay: number) => { + const healthy = definePlatform({ + id: 'healthy', apiVersion: '1', deliveryType: 'plugin', + createSession: () => ({ + async createPackage({ project, assets }) { + await new Promise(resolve => setTimeout(resolve, healthyDelay)); + const asset = await assets.fromBytes({ bytes: 'stable\n', origin: { operation: 'stable-package' } }); + return { + documents: [], assets: [{ path: 'stable.txt', asset }], + compatibility: project.commands.map(command => ({ + subject: `command:${command.id}`, capability: 'component', level: 'native' as const, reason: 'Native command.', + })), + metadata: metadata(), + }; + }, + finalizePackage: () => ({ id: 'plugin', type: 'plugin' as const }), + validatePackage: () => undefined, + }), + }); + const broken = definePlatform({ + id: 'broken', apiVersion: '1', deliveryType: 'plugin', + createSession: () => ({ + async createPackage() { + await new Promise(resolve => setTimeout(resolve, brokenDelay)); + throw new Error('nondeterministic raw failure'); + }, + finalizePackage: () => ({ id: 'plugin', type: 'plugin' as const }), + validatePackage: () => undefined, + }), + }); + const resolved = resolveKernelConfig({ + name: 'parallel-platforms', version: '1.0.0', description: 'Parallel Platforms.', + platforms: [healthy, broken], public: false, + }, { + projectRoot: root, configFile: path.join(root, 'acplugin.config.ts'), command: 'inspect', mode: 'production', + }); + return (await runKernelBuildSession({ config: resolved.config!, frameworkVersion: 'test', commit: false })).report; + }; + + const first = await execute(20, 0); + const second = await execute(0, 20); + expect(first).toEqual(second); + expect(first.success).toBe(false); + expect(first.platforms).toContainEqual({ id: 'healthy', selected: true, success: true, packageIds: ['plugin'] }); + expect(first.platforms).toContainEqual({ id: 'broken', selected: true, success: false, packageIds: [] }); + expect(first.packages).toContainEqual(expect.objectContaining({ platform: 'healthy', validated: true })); + expect(first.diagnostics).toContainEqual(expect.objectContaining({ code: 'PLATFORM_CREATE_PACKAGE_FAILED', platform: 'broken' })); + }); + + it.each([ + ['createPackage', 'package', 'PLATFORM_CREATE_PACKAGE_FAILED'], + ['contribute', 'contribute', 'PLATFORM_CONTRIBUTION_FAILED'], + ['finalizePackage', 'finalize', 'PLATFORM_FINALIZE_PACKAGE_FAILED'], + ['createDistributions', 'finalize', 'PLATFORM_FINALIZE_PACKAGE_FAILED'], + ['materialize', 'materialize', 'PACKAGE_CANDIDATE_MATERIALIZATION_FAILED'], + ['validatePackage', 'platform-validate', 'PLATFORM_VALIDATE_PACKAGE_FAILED'], + ] as const)('reports %s failure in its exact stage while another Platform completes', async (failure, phase, code) => { + const root = await fixture(); + /** 只有 Contribution case 需要 addons root,其他 case 删除未认领 Extension 来源。 */ + if (failure !== 'contribute') + await fs.rm(path.join(root, 'src', 'addons'), { recursive: true }); + /** 两个平台共享的有效 base output 让断言只改变目标阶段。 */ + const platform = (id: 'healthy' | 'broken') => definePlatform({ + id, + apiVersion: '1', + deliveryType: 'plugin', + createSession: () => ({ + async createPackage({ project, assets }) { + if (id === 'broken' && failure === 'createPackage') + throw new Error('raw create failure'); + const asset = await assets.fromBytes({ bytes: `${id}\n`, origin: { operation: 'stage-probe' } }); + return { + documents: [], + assets: [{ path: `${id}.txt`, asset }], + compatibility: project.commands.map(command => ({ + subject: `command:${command.id}`, + capability: 'component', + level: 'native' as const, + reason: 'The stage probe delivers the command natively.', + })), + metadata: metadata(), + }; + }, + finalizePackage() { + if (id === 'broken' && failure === 'finalizePackage') + throw new Error('raw finalize failure'); + return { id: 'plugin', type: 'plugin' as const }; + }, + async validatePackage({ candidate }) { + if (id !== 'broken') + return; + if (failure === 'validatePackage') + throw new Error('raw validator failure'); + if (failure === 'materialize') + await fs.writeFile(path.join(candidate.root, 'unexpected.txt'), 'mutation\n'); + }, + createDistributions() { + if (id === 'broken' && failure === 'createDistributions') + throw new Error('raw distribution failure'); + return []; + }, + }), + }); + /** Contribution failure 由真实 Extension Contributor 抛出,不能归到 package。 */ + const extension = failure === 'contribute' + ? defineExtension({ + id: 'stage-probe', + apiVersion: '1', + resourceRoots: ['addons'], + createSession: () => ({ + discover: () => ({}), + validate: (_context, discovered) => ({ state: discovered, subjects: [] }), + build: (_context, validated) => ({ state: validated }), + contributors: [{ + platform: 'broken', + platformApiVersion: '1', + contribute: () => { throw new Error('raw contribution failure'); }, + }], + }), + }) + : undefined; + const resolved = resolveKernelConfig({ + name: 'stage-boundaries', + version: '1.0.0', + description: 'Stage diagnostic boundaries.', + platforms: [platform('healthy'), platform('broken')], + ...(extension === undefined ? {} : { extensions: [extension] }), + public: false, + }, { + projectRoot: root, + configFile: path.join(root, 'acplugin.config.ts'), + command: 'inspect', + mode: 'production', + }); + const result = await runKernelBuildSession({ config: resolved.config!, frameworkVersion: 'test', commit: false }); + + expect(result.report.success).toBe(false); + expect(result.report.platforms).toContainEqual({ id: 'healthy', selected: true, success: true, packageIds: ['plugin'] }); + expect(result.report.platforms).toContainEqual({ id: 'broken', selected: true, success: false, packageIds: [] }); + expect(result.report.packages).toContainEqual(expect.objectContaining({ platform: 'healthy', validated: true })); + expect(result.report.packages.some(unit => unit.platform === 'broken')).toBe(false); + expect(result.report.diagnostics.filter(item => item.platform === 'broken')).toContainEqual(expect.objectContaining({ + code, + phase, + owner: 'platform:broken', + })); + expect(JSON.stringify(result.report)).not.toContain('raw '); + }); + + it('preserves Extension build failure ownership and blocks only matching consumers', async () => { + const root = await fixture(); + /** 当前用例的两个 Extension 不声明 Resource root,移除通用 addons fixture。 */ + await fs.rm(path.join(root, 'src', 'addons'), { recursive: true }); + /** 两个平台的阶段调用用于证明 failed Built State 在 createPackage 前完成路由。 */ + const created: string[] = []; + /** 独立 Platform 使用 relaxed strict 以保留 failed Extension 的显式 unsupported tuple。 */ + const platform = (id: 'supported' | 'independent') => definePlatform({ + id, + apiVersion: '1', + deliveryType: 'plugin', + strict: false, + createSession: () => ({ + createPackage: ({ project }) => { + created.push(id); + return { + documents: [], + assets: [], + compatibility: project.commands.map(command => ({ + subject: `command:${command.id}`, + capability: 'component', + level: 'native' as const, + reason: 'The fixture delivers canonical commands.', + })), + metadata: metadata(), + }; + }, + finalizePackage: () => ({ id: 'plugin', type: 'plugin' as const }), + validatePackage: () => undefined, + }), + }); + /** build throw 是当前失败唯一权威来源;Contributor 只匹配 supported。 */ + const failed = defineExtension({ + id: 'failed-extension', + apiVersion: '1', + resourceRoots: [], + createSession: () => ({ + discover: () => ({}), + validate: (_context, state) => ({ + state, + subjects: [{ subject: 'failed:resource', capabilities: ['delivery'] }], + }), + build: () => { throw new Error('raw failed Extension build'); }, + contributors: [{ + platform: 'supported', + platformApiVersion: '1', + contribute: () => ({ + compatibility: [{ + subject: 'failed:resource', capability: 'delivery', level: 'native', reason: 'Unreachable.', + }], + }), + }], + }), + }); + /** 成功 Extension 只服务 independent,证明独立 build/contribution 继续执行。 */ + const successful = defineExtension, Record, Record, { readonly asset: BytesAssetRef }>({ + id: 'successful-extension', + apiVersion: '1', + resourceRoots: [], + createSession: () => ({ + discover: () => ({}), + validate: (_context, state) => ({ + state, + subjects: [{ subject: 'successful:resource', capabilities: ['delivery'] }], + }), + async build({ assets }) { + return { state: { asset: await assets.fromBytes({ bytes: 'ready\n', origin: { operation: 'successful-extension' } }) } }; + }, + contributors: [{ + platform: 'independent', + platformApiVersion: '1', + contribute: (_context, state) => ({ + assets: [{ path: 'successful.txt', asset: state.asset }], + compatibility: [{ + subject: 'successful:resource', capability: 'delivery', level: 'native', reason: 'Delivered independently.', + }], + }), + }], + }), + }); + const resolved = resolveKernelConfig({ + name: 'extension-failure-ownership', + version: '1.0.0', + description: 'Extension failure ownership fixture.', + platforms: [platform('supported'), platform('independent')], + extensions: [failed, successful], + public: false, + }, { + projectRoot: root, + configFile: path.join(root, 'acplugin.config.ts'), + command: 'inspect', + mode: 'production', + }); + const result = await runKernelBuildSession({ config: resolved.config!, frameworkVersion: 'test', commit: false }); + + expect(result.report.success).toBe(false); + expect(created).toEqual(['independent']); + expect(result.report.platforms).toEqual(expect.arrayContaining([ + { id: 'supported', selected: true, success: false, packageIds: [] }, + { id: 'independent', selected: true, success: true, packageIds: ['plugin'] }, + ])); + expect(result.report.packages).toContainEqual(expect.objectContaining({ platform: 'independent', validated: true })); + expect(result.report.packages.some(unit => unit.platform === 'supported')).toBe(false); + expect(result.report.compatibility).toEqual(expect.arrayContaining([ + expect.objectContaining({ platform: 'independent', subject: 'failed:resource', capability: 'delivery', level: 'unsupported' }), + expect.objectContaining({ platform: 'independent', subject: 'successful:resource', capability: 'delivery', level: 'native' }), + ])); + expect(result.report.diagnostics.filter(diagnostic => diagnostic.code === 'EXTENSION_BUILD_FAILED')).toHaveLength(1); + expect(result.report.diagnostics.some(diagnostic => diagnostic.code === 'PLATFORM_CONTRIBUTION_FAILED')).toBe(false); + expect(JSON.stringify(result.report)).not.toContain('raw failed'); + }); + + it('uses exactly one aggregate or transaction materialization after candidate validation', async () => { + /** 每个执行建立独立环境,以 materializationBytes 次数识别完整物化路径。 */ + const execute = async (command: 'validate' | 'inspect' | 'build', withExtensionError = false) => { + const root = await fixture(); + await fs.rm(path.join(root, 'src', 'addons'), { recursive: true }); + await fs.rm(path.join(root, 'public'), { recursive: true }); + const environment = await createKernelBuildEnvironment(root); + /** 当前 Platform 的唯一 Asset 每次完整物化恰好读取一次。 */ + const reads = vi.spyOn(environment.assets, 'materializationBytes'); + const platform = definePlatform({ + id: 'materialization-probe', apiVersion: '1', deliveryType: 'plugin', + createSession: () => ({ + async createPackage({ project, assets }) { + const asset = await assets.fromBytes({ bytes: 'probe\n', origin: { operation: 'materialization-probe' } }); + return { + documents: [], assets: [{ path: 'probe.txt', asset }], + compatibility: project.commands.map(item => ({ + subject: `command:${item.id}`, capability: 'component', level: 'native' as const, reason: 'Native command.', + })), + metadata: metadata(), + }; + }, + finalizePackage: () => ({ id: 'plugin', type: 'plugin' as const }), + validatePackage: () => undefined, + }), + }); + /** Extension-owned validation error prevents commit without becoming a project-wide package blocker。 */ + const extension = withExtensionError + ? defineExtension({ + id: 'materialization-error', apiVersion: '1', resourceRoots: [], + createSession: () => ({ + discover: () => ({}), + validate(context, state) { + context.diagnostics.report({ code: 'MATERIALIZATION_FIXTURE_ERROR', severity: 'error', message: 'Fixture error.' }); + return { state, subjects: [{ subject: 'fixture:error', capabilities: ['delivery'] }] }; + }, + build: (_context, state) => ({ state }), + contributors: [], + }), + }) + : undefined; + const resolved = resolveKernelConfig({ + name: 'materialization-probe', version: '1.0.0', description: 'Materialization probe.', + platforms: [platform], + ...(extension === undefined ? {} : { extensions: [extension] }), + public: false, + }, { + projectRoot: root, configFile: path.join(root, 'acplugin.config.ts'), command, mode: 'production', + }); + try { + const result = await runKernelBuildSession({ + config: resolved.config!, frameworkVersion: 'test', commit: command === 'build', environment, + }); + return { report: result.report, reads: reads.mock.calls.length }; + } finally { + reads.mockRestore(); + await disposeKernelBuildEnvironment(environment); + } + }; + + /** validate/inspect 使用 candidate + aggregate;clean build 使用 candidate + transaction。 */ + for (const command of ['validate', 'inspect'] as const) { + const result = await execute(command); + expect(result.report.success).toBe(true); + expect(result.reads).toBe(2); + } + const committed = await execute('build'); + expect(committed.report.success).toBe(true); + expect(committed.report.committed).toBe(true); + expect(committed.reads).toBe(2); + /** build error 阻止 transaction,因此仍必须保留 candidate + aggregate 两次读取。 */ + const failed = await execute('build', true); + expect(failed.report.success).toBe(false); + expect(failed.report.committed).toBe(false); + expect(failed.reads).toBe(2); + }); + + it('compiles built-in Runtime once and contributes identical refs to capability-compatible Platforms', async () => { + const root = await fixture(); + await fs.rm(path.join(root, 'src', 'addons'), { recursive: true }); + await fs.mkdir(path.join(root, 'src', 'runtime'), { recursive: true }); + await fs.writeFile(path.join(root, 'src', 'runtime', 'cli.ts'), 'process.stdout.write("runtime-ready\\n");\n'); + /** 两个支持 Platform 必须读取到同一份 Bundle 字节与 executable mode。 */ + const observed: { platform: string; bytes: Uint8Array }[] = []; + const supported = (id: string) => definePlatform({ + id, apiVersion: '1', deliveryType: 'plugin', + capabilities: { nodeRuntime: { target: 'node20', format: 'esm', root: 'plugin' } }, + createSession: () => ({ + createPackage: ({ project }) => ({ + documents: [], assets: [], + compatibility: project.commands.map(command => ({ + subject: `command:${command.id}`, capability: 'component', level: 'native' as const, reason: 'Native command.', + })), + metadata: metadata(), + }), + finalizePackage: () => ({ id: 'plugin', type: 'plugin' as const }), + async validatePackage({ candidate }) { + /** Candidate 物化证明 Runtime 由 Framework Contribution 自动继承。 */ + const runtime = await fs.readFile(path.join(candidate.root, 'runtime', 'cli', 'main.mjs')); + observed.push({ platform: id, bytes: runtime }); + expect(candidate.unit.assets.find(asset => asset.path === 'runtime/cli/main.mjs')).toMatchObject({ + owner: 'framework:node-runtime', + }); + }, + }), + }); + const resolved = resolveKernelConfig({ + name: 'runtime-build', version: '1.0.0', description: 'Runtime build.', + platforms: [supported('alpha'), supported('beta')], public: false, + }, { + projectRoot: root, configFile: path.join(root, 'acplugin.config.ts'), command: 'inspect', mode: 'production', + }); + const result = await runKernelBuildSession({ config: resolved.config!, frameworkVersion: 'test', commit: false }); + + expect(result.report.success, JSON.stringify(result.report.diagnostics, null, 2)).toBe(true); + expect(result.report.runtimes).toEqual([{ + id: 'cli', kind: 'executable', location: { path: 'src/runtime/cli.ts' }, built: true, + }]); + expect(result.watch.paths).toContain(await fs.realpath(path.join(root, 'src', 'runtime', 'cli.ts'))); + expect(result.report.compatibility.filter(entry => entry.subject === 'runtime:cli')).toEqual([ + { + platform: 'alpha', subject: 'runtime:cli', capability: 'node20-esm', level: 'native', + reason: 'The platform can install and execute the bundled Node.js runtime.', + }, + { + platform: 'beta', subject: 'runtime:cli', capability: 'node20-esm', level: 'native', + reason: 'The platform can install and execute the bundled Node.js runtime.', + }, + ]); + expect(observed).toHaveLength(2); + expect(observed[0]!.bytes).toEqual(observed[1]!.bytes); + const runtimeAssets = result.report.packages.flatMap(unit => unit.assets.filter(asset => asset.path === 'runtime/cli/main.mjs')); + expect(runtimeAssets).toHaveLength(2); + expect(runtimeAssets.map(asset => [asset.mode, asset.sha256, asset.origin])).toEqual([ + [0o755, runtimeAssets[0]!.sha256, runtimeAssets[0]!.origin], + [0o755, runtimeAssets[0]!.sha256, runtimeAssets[0]!.origin], + ]); + }); + + it('preserves Runtime owner, bytes, hash and origin through Marketplace inheritance', async () => { + const root = await fixture(); + await fs.rm(path.join(root, 'src', 'addons'), { recursive: true }); + await fs.mkdir(path.join(root, 'src', 'runtime'), { recursive: true }); + await fs.writeFile(path.join(root, 'src', 'runtime', 'main.ts'), 'process.stdout.write("marketplace-runtime\\n");\n'); + const platform = definePlatform({ + id: 'marketplace-runtime', apiVersion: '1', deliveryType: 'plugin', + capabilities: { nodeRuntime: { target: 'node20', format: 'esm', root: 'plugin' } }, + createSession: () => ({ + createPackage: ({ project }) => ({ + documents: [], assets: [], + compatibility: project.commands.map(command => ({ + subject: `command:${command.id}`, capability: 'component', level: 'native' as const, reason: 'Native command.', + })), + metadata: metadata(), + }), + finalizePackage: () => ({ id: 'plugin', type: 'plugin' as const }), + validatePackage: () => undefined, + createDistributions: ({ primary }) => [{ + id: 'marketplace', type: 'marketplace' as const, + assets: primary.assets.map(asset => ({ path: `plugin/${asset.path}`, asset: asset.asset })), + }], + }), + }); + const resolved = resolveKernelConfig({ + name: 'runtime-marketplace', version: '1.0.0', description: 'Runtime Marketplace.', platforms: [platform], public: false, + }, { + projectRoot: root, configFile: path.join(root, 'acplugin.config.ts'), command: 'inspect', mode: 'production', + }); + const result = await runKernelBuildSession({ config: resolved.config!, frameworkVersion: 'test', commit: false }); + + expect(result.report.success, JSON.stringify(result.report.diagnostics, null, 2)).toBe(true); + const primary = result.report.packages.find(unit => unit.role === 'primary')!.assets + .find(asset => asset.path === 'runtime/main/main.mjs')!; + const distribution = result.report.packages.find(unit => unit.role === 'distribution')!.assets + .find(asset => asset.path === 'plugin/runtime/main/main.mjs')!; + expect(distribution).toEqual({ ...primary, path: 'plugin/runtime/main/main.mjs' }); + }); + + it('skips broken Runtime compilation when every selected Platform is unsupported', async () => { + const root = await fixture(); + await fs.rm(path.join(root, 'src', 'addons'), { recursive: true }); + await fs.mkdir(path.join(root, 'src', 'runtime'), { recursive: true }); + /** 该 import 若被 portable-node 执行必然失败,用于证明 capability 协商发生在 compile 前。 */ + await fs.writeFile(path.join(root, 'src', 'runtime', 'cli.ts'), 'import "missing-runtime-package";\n'); + const platform = definePlatform({ + id: 'unsupported', apiVersion: '1', deliveryType: 'plugin', strict: false, + createSession: () => ({ + createPackage: ({ project }) => ({ + documents: [], assets: [], + compatibility: project.commands.map(command => ({ + subject: `command:${command.id}`, capability: 'component', level: 'native' as const, reason: 'Native command.', + })), + metadata: metadata(), + }), + finalizePackage: () => ({ id: 'plugin', type: 'plugin' as const }), + validatePackage: () => undefined, + }), + }); + const resolved = resolveKernelConfig({ + name: 'runtime-skip', version: '1.0.0', description: 'Runtime skip.', platforms: [platform], public: false, + }, { + projectRoot: root, configFile: path.join(root, 'acplugin.config.ts'), command: 'inspect', mode: 'production', + }); + const result = await runKernelBuildSession({ config: resolved.config!, frameworkVersion: 'test', commit: false }); + + expect(result.report.success, JSON.stringify(result.report.diagnostics, null, 2)).toBe(true); + expect(result.report.runtimes).toEqual([{ + id: 'cli', kind: 'executable', location: { path: 'src/runtime/cli.ts' }, built: false, + }]); + expect(result.report.packages.flatMap(unit => unit.assets).some(asset => asset.path.startsWith('runtime/'))).toBe(false); + expect(result.report.compatibility).toContainEqual({ + platform: 'unsupported', subject: 'runtime:cli', capability: 'node20-esm', level: 'unsupported', + reason: 'The platform does not provide a stable Plugin-local Node.js runtime.', + }); + expect(result.report.diagnostics).toContainEqual(expect.objectContaining({ + code: 'COMPATIBILITY_RELAXED', severity: 'warning', platform: 'unsupported', + })); + expect(result.report.diagnostics.some(item => item.code === 'NODE_RUNTIME_BUILD_FAILED')).toBe(false); + }); + + it('delivers Runtime only to supported Platforms in a mixed capability build', async () => { + const root = await fixture(); + await fs.rm(path.join(root, 'src', 'addons'), { recursive: true }); + await fs.mkdir(path.join(root, 'src', 'runtime'), { recursive: true }); + await fs.writeFile(path.join(root, 'src', 'runtime', 'worker.mts'), 'export const worker = "ready";\n'); + const platform = (id: string, runtime: boolean) => definePlatform({ + id, apiVersion: '1', deliveryType: 'plugin', strict: false, + ...(runtime ? { capabilities: { nodeRuntime: { target: 'node20' as const, format: 'esm' as const, root: 'plugin' as const } } } : {}), + createSession: () => ({ + createPackage: ({ project }) => ({ + documents: [], assets: [], + compatibility: project.commands.map(command => ({ + subject: `command:${command.id}`, capability: 'component', level: 'native' as const, reason: 'Native command.', + })), + metadata: metadata(), + }), + finalizePackage: () => ({ id: 'plugin', type: 'plugin' as const }), + validatePackage: () => undefined, + }), + }); + const resolved = resolveKernelConfig({ + name: 'runtime-mixed', version: '1.0.0', description: 'Runtime mixed.', + platforms: [platform('supported', true), platform('unsupported', false)], public: false, + runtime: { entries: { worker: { entry: 'worker.mts', kind: 'module' } }, compile: { treeshake: false } }, + }, { + projectRoot: root, configFile: path.join(root, 'acplugin.config.ts'), command: 'inspect', mode: 'production', + }); + const result = await runKernelBuildSession({ config: resolved.config!, frameworkVersion: 'test', commit: false }); + + expect(result.report.success, JSON.stringify(result.report.diagnostics, null, 2)).toBe(true); + expect(result.report.runtimes).toContainEqual(expect.objectContaining({ id: 'worker', kind: 'module', built: true })); + const supported = result.report.packages.find(unit => unit.platform === 'supported')!; + const unsupported = result.report.packages.find(unit => unit.platform === 'unsupported')!; + expect(supported.assets).toContainEqual(expect.objectContaining({ + path: 'runtime/worker/main.mjs', owner: 'framework:node-runtime', mode: 0o644, + })); + expect(unsupported.assets.some(asset => asset.path.startsWith('runtime/'))).toBe(false); + expect(result.report.compatibility.filter(entry => entry.subject === 'runtime:worker').map(entry => [entry.platform, entry.level])).toEqual([ + ['supported', 'native'], + ['unsupported', 'unsupported'], + ]); + }); + + it('fails all package finalization when a selected supported Runtime build fails', async () => { + const root = await fixture(); + await fs.rm(path.join(root, 'src', 'addons'), { recursive: true }); + await fs.mkdir(path.join(root, 'src', 'runtime'), { recursive: true }); + await fs.writeFile(path.join(root, 'src', 'runtime', 'cli.ts'), 'import "missing-runtime-package";\n'); + /** finalize 调用数证明 Runtime build failure 位于全部 Platform finalization 之前。 */ + let finalized = 0; + const platform = definePlatform({ + id: 'supported', apiVersion: '1', deliveryType: 'plugin', + capabilities: { nodeRuntime: { target: 'node20', format: 'esm', root: 'plugin' } }, + createSession: () => ({ + createPackage: ({ project }) => ({ + documents: [], assets: [], + compatibility: project.commands.map(command => ({ + subject: `command:${command.id}`, capability: 'component', level: 'native' as const, reason: 'Native command.', + })), + metadata: metadata(), + }), + finalizePackage: () => { + finalized += 1; + return { id: 'plugin', type: 'plugin' as const }; + }, + validatePackage: () => undefined, + }), + }); + const resolved = resolveKernelConfig({ + name: 'runtime-failure', version: '1.0.0', description: 'Runtime failure.', platforms: [platform], public: false, + }, { + projectRoot: root, configFile: path.join(root, 'acplugin.config.ts'), command: 'build', mode: 'production', + }); + const result = await runKernelBuildSession({ config: resolved.config!, frameworkVersion: 'test', commit: true }); + + expect(result.report.success).toBe(false); + expect(result.report.committed).toBe(false); + expect(result.report.packages).toEqual([]); + expect(finalized).toBe(0); + expect(result.report.diagnostics).toContainEqual(expect.objectContaining({ + code: 'NODE_RUNTIME_BUILD_FAILED', phase: 'compile', owner: 'framework:node-runtime', + })); + }); +}); diff --git a/packages/core/test/lifecycle/dev-session-faults.test.ts b/packages/core/test/lifecycle/dev-session-faults.test.ts new file mode 100644 index 0000000..d40f651 --- /dev/null +++ b/packages/core/test/lifecycle/dev-session-faults.test.ts @@ -0,0 +1,251 @@ +import { EventEmitter } from 'node:events'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { DevSession, SourceFileRef } from '../../src/contracts/index.js'; +import { defineExtension, definePlatform } from '../../src/api/definitions.js'; +import { resolveKernelConfig } from '../../src/config/resolver.js'; +import { createDevSession, type DevSessionRoundInput } from '../../src/lifecycle/dev-session.js'; + +/** 当前套件创建的工程和外部 package 根。 */ +const roots: string[] = []; + +/** createDevSession 实际使用的最小、可故障注入 FSWatcher。 */ +class FaultWatcher extends EventEmitter { + /** 当前物理 watcher 已登记的精确路径。 */ + readonly paths = new Set(); + failAdd = false; + failUnwatch = false; + failGetWatched = false; + hideWatched = false; + failClose = false; + + constructor(paths: string | readonly string[]) { + super(); + this.addPaths(paths); + queueMicrotask(() => this.emit('ready')); + } + + /** 不经过 fault flag 的内部初始登记。 */ + private addPaths(input: string | readonly string[]): void { + for (const candidate of typeof input === 'string' ? [input] : input) + this.paths.add(path.resolve(candidate)); + } + + /** 模拟 Chokidar 同步 add。 */ + add(input: string | readonly string[]): this { + if (this.failAdd) + throw new Error('injected watcher add failure'); + this.addPaths(input); + return this; + } + + /** 模拟 Chokidar 异步 unwatch。 */ + async unwatch(input: string | readonly string[]): Promise { + if (this.failUnwatch) + throw new Error('injected watcher unwatch failure'); + for (const candidate of typeof input === 'string' ? [input] : input) + this.paths.delete(path.resolve(candidate)); + return this; + } + + /** 为 readiness 检查生成 directory → direct child 快照。 */ + getWatched(): Record { + if (this.failGetWatched) + throw new Error('injected watcher getWatched failure'); + if (this.hideWatched) + return {}; + const watched: Record = {}; + for (const candidate of this.paths) { + const directory = path.dirname(candidate); + (watched[directory] ??= []).push(path.basename(candidate)); + } + return watched; + } + + /** close 可以失败,但不改变 DevSession 必须发布的终态。 */ + async close(): Promise { + if (this.failClose) + throw new Error('injected watcher close failure'); + } + + /** 向 DevSession 发布一个真实 Chokidar all event。 */ + change(file: string): void { + this.emit('all', 'change', path.resolve(file)); + } +} + +/** watcher graph 是否包含外部 package 的可变 fixture 开关。 */ +interface FixtureControl { + compileExternal: boolean; +} + +/** 创建直接调用私有 Core coordinator 的确定性 dev fixture。 */ +async function fixture(initialExternal = false): Promise<{ + readonly root: string; + readonly command: string; + readonly control: FixtureControl; + readonly input: DevSessionRoundInput; + watcher(): FaultWatcher; +}> { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-dev-fault-')); + roots.push(root); + const packageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-dev-fault-package-')); + roots.push(packageRoot); + await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ + name: 'dev-fault-package', version: '1.0.0', type: 'module', exports: './index.js', license: 'MIT', + })); + await fs.writeFile(path.join(packageRoot, 'index.js'), 'export const value = true;\n'); + await fs.writeFile(path.join(packageRoot, 'LICENSE'), 'Dev fault package license.\n'); + await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/probe'), { recursive: true }); + await fs.mkdir(path.join(root, 'node_modules'), { recursive: true }); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), 'export default {};\n'); + const command = path.join(root, 'src/commands/review.md'); + await fs.writeFile(command, '---\ndescription: Review.\n---\nReview.\n'); + await fs.writeFile(path.join(root, 'src/probe/entry.ts'), 'export { value } from "dev-fault-package";\n'); + await fs.symlink(packageRoot, path.join(root, 'node_modules/dev-fault-package'), 'dir'); + const control: FixtureControl = { compileExternal: initialExternal }; + const platform = definePlatform({ + id: 'dev-fault', apiVersion: '1', deliveryType: 'plugin', + createSession: () => ({ + createPackage: ({ project }) => ({ + documents: [], assets: [], + compatibility: project.commands.map(item => ({ + subject: `command:${item.id}`, capability: 'component', level: 'native' as const, reason: 'Native command.', + })), + metadata: ['name', 'version', 'description'].map(field => ({ + field, disposition: 'emitted' as const, output: `manifest/${field}`, reason: 'Emitted metadata.', + })), + }), + finalizePackage: () => ({ id: 'plugin', type: 'plugin' as const }), + validatePackage: () => undefined, + }), + }); + const extension = defineExtension, { readonly entry: SourceFileRef }, { readonly entry: SourceFileRef }, Record>({ + id: 'dev-fault-extension', apiVersion: '1', resourceRoots: ['probe'], + createSession: () => ({ + discover: async context => ({ entry: await context.sources.file(context.roots.probe!, 'entry.ts') }), + validate: (_context, state) => ({ state, subjects: [] }), + async build(context, state) { + if (control.compileExternal) { + await context.compiler.compile({ + id: 'external', profile: 'portable-node', entries: { main: { type: 'source', source: state.entry } }, + }); + } + return { state: {} }; + }, + contributors: [{ + platform: 'dev-fault', platformApiVersion: '1', contribute: () => ({ compatibility: [] }), + }], + }), + }); + let currentWatcher: FaultWatcher | undefined; + /** 仅 Core 内部测试替换实际 Chokidar 工厂。 */ + const watchFactory = ((paths: string | readonly string[]) => { + currentWatcher = new FaultWatcher(paths); + return currentWatcher; + }) as unknown as NonNullable; + const input: DevSessionRoundInput = { + projectRoot: root, + configFile: path.join(root, 'acplugin.config.ts'), + frameworkVersion: 'test', + options: { mode: 'development', commit: false }, + watchFactory, + watchReadyTimeoutMs: 20, + loadConfig: async () => { + const resolved = resolveKernelConfig({ + name: 'dev-fault', version: '1.0.0', description: 'Dev watcher fault fixture.', + platforms: [platform], extensions: [extension], public: false, + }, { + projectRoot: root, + configFile: path.join(root, 'acplugin.config.ts'), + command: 'dev', + mode: 'development', + }); + if (resolved.config === undefined) + throw new Error('Fixture config failed.'); + return resolved.config; + }, + }; + return { + root, + command, + control, + input, + watcher: () => currentWatcher!, + }; +} + +/** 等待下一次公开 build-complete。 */ +function nextComplete(session: DevSession): Promise> { + return new Promise((resolve) => { + const unsubscribe = session.subscribe((event) => { + if (event.type === 'build-complete') { + unsubscribe(); + resolve(event); + } + }); + }); +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('DevSession watcher fault boundaries', () => { + it.each(['add', 'getWatched', 'readiness'] as const)('pairs start/complete and reports an injected %s failure', async (failure) => { + const current = await fixture(false); + const session = await createDevSession(current.input); + const watcher = current.watcher(); + if (failure === 'add') watcher.failAdd = true; + if (failure === 'getWatched') watcher.failGetWatched = true; + if (failure === 'readiness') watcher.hideWatched = true; + current.control.compileExternal = true; + /** 同一 sequence 的公开事件必须在 watcher I/O 失败时仍成对。 */ + const events: import('../../src/contracts/index.js').DevSessionEvent[] = []; + session.subscribe(event => events.push(event)); + const complete = nextComplete(session); + watcher.change(current.command); + const result = await complete; + + expect(result.report.success).toBe(false); + expect(result.report.diagnostics).toContainEqual(expect.objectContaining({ code: 'DEV_WATCH_FAILED', phase: 'dev' })); + expect(events.filter(event => event.type === 'build-start')).toHaveLength(1); + expect(events.filter(event => event.type === 'build-complete')).toHaveLength(1); + expect(events[0]?.sequence).toBe(events[1]?.sequence); + await session.close(); + await session.closed; + }); + + it('keeps logical state uncommitted when unwatch rejects', async () => { + const current = await fixture(true); + const session = await createDevSession(current.input); + const watcher = current.watcher(); + current.control.compileExternal = false; + watcher.failUnwatch = true; + const complete = nextComplete(session); + watcher.change(current.command); + + const result = await complete; + expect(result.report.success).toBe(false); + expect(result.report.diagnostics).toContainEqual(expect.objectContaining({ code: 'DEV_WATCH_FAILED' })); + await session.close(); + }); + + it('settles closed and emits one terminal event before surfacing close failure', async () => { + const current = await fixture(false); + const session = await createDevSession(current.input); + current.watcher().failClose = true; + /** 显式观察 close rejection,防止测试本身制造 unhandledRejection。 */ + const events: import('../../src/contracts/index.js').DevSessionEvent[] = []; + session.subscribe(event => events.push(event)); + const close = session.close(); + + await expect(close).rejects.toThrow('DevSession cleanup failed'); + await expect(session.closed).resolves.toBeUndefined(); + expect(events.filter(event => event.type === 'closed')).toHaveLength(1); + expect(session.close()).toBe(close); + }); +}); diff --git a/packages/core/test/output/transaction.test.ts b/packages/core/test/output/transaction.test.ts new file mode 100644 index 0000000..96a21cf --- /dev/null +++ b/packages/core/test/output/transaction.test.ts @@ -0,0 +1,690 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { PackageUnitSnapshot } from '../../src/contracts/index.js'; +import { AssetRegistry } from '../../src/services/assets.js'; +import { BuildSessionScope } from '../../src/services/session-scope.js'; +import { SourceRegistry } from '../../src/services/sources.js'; +import { WorkDirectoryRegistry } from '../../src/services/work-directories.js'; +import { commitPackageUnits, type ManagedOutputPhase } from '../../src/output/transaction.js'; + +/** Transaction 测试统一清理的临时工程根。 */ +const roots: string[] = []; + +/** @returns 一个工程根和当前 BuildSession Asset Registry。 */ +async function fixture() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-transaction-v2-')); + roots.push(root); + const scope = new BuildSessionScope(); + const sources = new SourceRegistry(scope, root); + const work = new WorkDirectoryRegistry(scope, path.join(root, '.work')); + const assets = new AssetRegistry(scope, sources, work); + return { root, assets }; +} + +/** @returns 指定 Platform/version 的主 Package Unit。 */ +async function unit(assets: AssetRegistry, platform: string, version: string): Promise { + /** 当前 Platform 签发自己的稳定版本 Asset。 */ + const asset = await assets.service(`platform:${platform}`).fromBytes({ + bytes: version, origin: { operation: 'version' }, + }); + return Object.freeze({ + platform, id: 'plugin', type: 'plugin', role: 'primary', + assets: Object.freeze([{ path: 'version.txt', owner: `platform:${platform}`, asset }]), + compatibility: Object.freeze([]), metadata: Object.freeze([]), + }); +} + +/** @returns 当前工程内的受管输出辅助文件。 */ +async function helpers(root: string): Promise { + return (await fs.readdir(root)) + .filter(name => name.startsWith('.dist.acplugin-') || name.startsWith('.dist.acplugin.lock')) + .sort(); +} + +/** 写入模拟进程崩溃后遗留的稳定事务 marker。 */ +async function transactionMarker(root: string, name: 'transaction' | 'committed', hadOutput: boolean): Promise { + await fs.writeFile(path.join(root, `.dist.acplugin-${name}.json`), `${JSON.stringify({ + schemaVersion: 2, + outDir: 'dist', + scope: 'full', + hadOutput, + })}\n`); +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('Package Unit atomic transaction', () => { + it('preserves the previous complete output at every fault-injection phase', async () => { + const phases: readonly ManagedOutputPhase[] = [ + 'lock-acquired', 'recovery-complete', 'stage-materialized', 'stage-validated', + 'transaction-written', 'backup-created', 'output-swapped', + ]; + for (const phase of phases) { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + await fs.mkdir(path.join(outDir, 'target', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'old'); + const packageUnit = await unit(current.assets, 'target', 'new'); + + await expect(commitPackageUnits(outDir, [packageUnit], current.assets, { + projectRoot: current.root, + onPhase(stage) { + if (stage === phase) + throw new Error(`fail at ${phase}`); + }, + })).rejects.toThrow(`fail at ${phase}`); + + expect(await fs.readFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'utf8')).toBe('old'); + expect(await helpers(current.root)).toEqual([]); + } + }); + + it('rolls back when atomic committed-marker publication fails', async () => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + await fs.mkdir(path.join(outDir, 'target', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'old'); + const packageUnit = await unit(current.assets, 'target', 'new'); + /** 只注入 committed marker,避免 lock/guard 的原子发布改变调用序号。 */ + const realLink = fs.link.bind(fs); + const link = vi.spyOn(fs, 'link').mockImplementation(async (existingPath, newPath) => { + if (String(newPath).endsWith('.dist.acplugin-committed.json')) + throw new Error('committed marker publication failed'); + await realLink(existingPath, newPath); + }); + try { + await expect(commitPackageUnits(outDir, [packageUnit], current.assets, { + projectRoot: current.root, + })).rejects.toThrow('committed marker publication failed'); + } finally { + link.mockRestore(); + } + + expect(await fs.readFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'utf8')).toBe('old'); + expect(await helpers(current.root)).toEqual([]); + }); + + it.each(['writeFile', 'sync'] as const)( + 'cleans a final lock draft when %s fails', + async (operation) => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + const lock = path.join(current.root, '.dist.acplugin.lock'); + await fs.mkdir(path.join(outDir, 'target', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'old'); + const realOpen = fs.open.bind(fs); + let injected = false; + const open = vi.spyOn(fs, 'open').mockImplementation(async (file, flags, mode) => { + const handle = await realOpen(file, flags, mode); + const candidate = String(file); + if (!injected && candidate.startsWith(`${lock}.`) && candidate.endsWith('.writing') + && !candidate.startsWith(`${lock}.guard.`)) { + injected = true; + vi.spyOn(handle, operation).mockRejectedValueOnce(new Error(`lock ${operation} failed`)); + } + return handle; + }); + try { + await expect(commitPackageUnits(outDir, [], current.assets, { projectRoot: current.root })) + .rejects.toThrow(`lock ${operation} failed`); + } finally { + open.mockRestore(); + } + + expect(injected).toBe(true); + expect(await fs.readFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'utf8')).toBe('old'); + expect(await helpers(current.root)).toEqual([]); + }, + ); + + it('retries a transient final lock close failure without leaking its handle or draft', async () => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + const lock = path.join(current.root, '.dist.acplugin.lock'); + const realOpen = fs.open.bind(fs); + let injected = false; + const open = vi.spyOn(fs, 'open').mockImplementation(async (file, flags, mode) => { + const handle = await realOpen(file, flags, mode); + const candidate = String(file); + if (!injected && candidate.startsWith(`${lock}.`) && candidate.endsWith('.writing') + && !candidate.startsWith(`${lock}.guard.`)) { + injected = true; + vi.spyOn(handle, 'close').mockRejectedValueOnce(new Error('lock close failed')); + } + return handle; + }); + try { + await commitPackageUnits(outDir, [], current.assets, { projectRoot: current.root }); + } finally { + open.mockRestore(); + } + + expect(injected).toBe(true); + expect(await helpers(current.root)).toEqual([]); + }); + + it('cleans a final lock draft when atomic publication fails', async () => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + const lock = path.join(current.root, '.dist.acplugin.lock'); + const realLink = fs.link.bind(fs); + let injected = false; + const link = vi.spyOn(fs, 'link').mockImplementation(async (existingPath, newPath) => { + if (!injected && String(newPath) === lock) { + injected = true; + throw new Error('lock publication failed'); + } + await realLink(existingPath, newPath); + }); + try { + await expect(commitPackageUnits(outDir, [], current.assets, { projectRoot: current.root })) + .rejects.toThrow('lock publication failed'); + } finally { + link.mockRestore(); + } + + expect(injected).toBe(true); + expect(await helpers(current.root)).toEqual([]); + }); + + it.each(['draft', 'record'] as const)( + 'retries a transient final lock %s removal failure', + async (target) => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + const lock = path.join(current.root, '.dist.acplugin.lock'); + const realRm = fs.rm.bind(fs); + let injected = false; + const rm = vi.spyOn(fs, 'rm').mockImplementation(async (file, options) => { + const candidate = String(file); + const finalDraft = candidate.startsWith(`${lock}.`) && candidate.endsWith('.writing') + && !candidate.startsWith(`${lock}.guard.`); + if (!injected && (target === 'record' ? candidate === lock : finalDraft)) { + injected = true; + throw new Error(`lock ${target} remove failed`); + } + await realRm(file, options); + }); + try { + await commitPackageUnits(outDir, [], current.assets, { projectRoot: current.root }); + } finally { + rm.mockRestore(); + } + + expect(injected).toBe(true); + expect(await helpers(current.root)).toEqual([]); + }, + ); + + it('replaces the full configured output set atomically', async () => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + await fs.mkdir(path.join(outDir, 'stale', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'stale', 'plugin', 'version.txt'), 'stale'); + + await commitPackageUnits(outDir, [await unit(current.assets, 'target', 'new')], current.assets, { + projectRoot: current.root, + scope: { type: 'full' }, + }); + + expect(await fs.readFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'utf8')).toBe('new'); + await expect(fs.access(path.join(outDir, 'stale'))).rejects.toThrow(); + if (process.platform !== 'win32') { + for (const directory of [outDir, path.join(outDir, 'target'), path.join(outDir, 'target', 'plugin')]) + expect((await fs.stat(directory)).mode & 0o777).toBe(0o755); + expect((await fs.stat(path.join(outDir, 'target', 'plugin', 'version.txt'))).mode & 0o777).toBe(0o644); + } + }); + + it('replaces an explicit subset while preserving validated unselected Platform bytes and mode', async () => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + const preserved = path.join(outDir, 'other', 'plugin', 'bin', 'main.mjs'); + await fs.mkdir(path.dirname(preserved), { recursive: true }); + await fs.writeFile(preserved, 'old-other', { mode: 0o755 }); + await fs.mkdir(path.join(outDir, 'target', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'old-target'); + /** 旧输出的私有目录 mode 不得原样污染新的 subset stage。 */ + if (process.platform !== 'win32') { + for (const directory of [outDir, path.join(outDir, 'other'), path.join(outDir, 'other', 'plugin'), path.dirname(preserved), path.join(outDir, 'target'), path.join(outDir, 'target', 'plugin')]) + await fs.chmod(directory, 0o700); + } + + await commitPackageUnits(outDir, [await unit(current.assets, 'target', 'new-target')], current.assets, { + projectRoot: current.root, + scope: { type: 'subset', platforms: ['target'] }, + }); + + expect(await fs.readFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'utf8')).toBe('new-target'); + expect(await fs.readFile(preserved, 'utf8')).toBe('old-other'); + expect((await fs.stat(preserved)).mode & 0o777).toBe(0o755); + if (process.platform !== 'win32') { + for (const directory of [ + outDir, + path.join(outDir, 'other'), + path.join(outDir, 'other', 'plugin'), + path.dirname(preserved), + path.join(outDir, 'target'), + path.join(outDir, 'target', 'plugin'), + ]) expect((await fs.stat(directory)).mode & 0o777).toBe(0o755); + expect((await fs.stat(path.join(outDir, 'target', 'plugin', 'version.txt'))).mode & 0o777).toBe(0o644); + } + }); + + it('rejects a stage directory mode mutation before swap', async () => { + if (process.platform === 'win32') + return; + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + await fs.mkdir(path.join(outDir, 'target', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'old'); + + await expect(commitPackageUnits(outDir, [await unit(current.assets, 'target', 'new')], current.assets, { + projectRoot: current.root, + async onPhase(phase) { + if (phase !== 'stage-materialized') + return; + /** fault injection 只定位当前事务唯一 stage,不依赖随机 suffix。 */ + const stage = (await fs.readdir(current.root)).find(name => name.startsWith('.dist.acplugin-stage-'))!; + await fs.chmod(path.join(current.root, stage, 'target'), 0o700); + }, + })).rejects.toThrow('mode 0755'); + + expect(await fs.readFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'utf8')).toBe('old'); + expect(await helpers(current.root)).toEqual([]); + }); + + it('rejects unsafe subset trees and Platform set mismatches before swap', async () => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + await fs.mkdir(path.join(outDir, 'other', 'plugin'), { recursive: true }); + await fs.symlink(current.root, path.join(outDir, 'other', 'plugin', 'escape')); + const packageUnit = await unit(current.assets, 'target', 'new'); + + await expect(commitPackageUnits(outDir, [packageUnit], current.assets, { + projectRoot: current.root, scope: { type: 'subset', platforms: ['target'] }, + })).rejects.toThrow('symbolic link'); + await expect(commitPackageUnits(outDir, [packageUnit], current.assets, { + projectRoot: current.root, scope: { type: 'subset', platforms: ['other'] }, + })).rejects.toThrow('exactly match'); + }); + + it('rejects source TOCTOU and leaves old output intact', async () => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + await fs.mkdir(path.join(outDir, 'target', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'old'); + const sourceRoot = path.join(current.root, 'public'); + await fs.mkdir(sourceRoot); + await fs.writeFile(path.join(sourceRoot, 'data.txt'), 'original'); + /** 本测试使用独立 Registry 公开 Source API 签发 TOCTOU ref。 */ + const scope = new BuildSessionScope(); + const sources = new SourceRegistry(scope, current.root); + const work = new WorkDirectoryRegistry(scope, path.join(current.root, '.work-source')); + const assets = new AssetRegistry(scope, sources, work); + const rootRef = await sources.issueRoot('framework:public', sourceRoot); + const source = await sources.service('framework:public').file(rootRef, 'data.txt'); + const asset = await assets.service('framework:public').fromSource(source); + assets.grant('framework:public', 'platform:target', asset); + const packageUnit: PackageUnitSnapshot = Object.freeze({ + platform: 'target', id: 'plugin', type: 'plugin', role: 'primary', + assets: Object.freeze([{ path: 'data.txt', owner: 'framework:public', asset }]), + compatibility: Object.freeze([]), metadata: Object.freeze([]), + }); + await fs.writeFile(path.join(sourceRoot, 'data.txt'), 'changed'); + + await expect(commitPackageUnits(outDir, [packageUnit], assets, { projectRoot: current.root })).rejects.toThrow('changed after'); + expect(await fs.readFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'utf8')).toBe('old'); + }); + + it('rejects project root and outside targets before creating transaction state', async () => { + const current = await fixture(); + const outside = await fixture(); + + await expect(commitPackageUnits(current.root, [], current.assets, { projectRoot: current.root })).rejects.toThrow('strictly inside'); + await expect(commitPackageUnits(path.join(outside.root, 'dist'), [], current.assets, { projectRoot: current.root })).rejects.toThrow('strictly inside'); + /** 现有 outDir 符号链接也不能被当成受管目录替换。 */ + const linkedOut = path.join(current.root, 'linked-dist'); + await fs.symlink(outside.root, linkedOut); + await expect(commitPackageUnits(linkedOut, [], current.assets, { projectRoot: current.root })).rejects.toThrow('symbolic links'); + expect(await helpers(current.root)).toEqual([]); + }); + + it('rejects a concurrent writer while the first transaction holds the lock', async () => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + let notifyLocked!: () => void; + let releaseLock!: () => void; + /** locked 与 gate 精确控制两个 transaction 的竞争窗口。 */ + const locked = new Promise((resolve) => { + notifyLocked = resolve; + }); + const gate = new Promise((resolve) => { + releaseLock = resolve; + }); + const first = commitPackageUnits(outDir, [], current.assets, { + projectRoot: current.root, + async onPhase(phase) { + if (phase === 'lock-acquired') { + notifyLocked(); + await gate; + } + }, + }); + await locked; + + await expect(commitPackageUnits(outDir, [], current.assets, { projectRoot: current.root })).rejects.toThrow('locked'); + releaseLock(); + await first; + expect(await helpers(current.root)).toEqual([]); + }); + + it('serializes stale-lock recovery before another writer can replace the observed record', async () => { + /** dead lock 让第一个事务进入 quarantine 临界区。 */ + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + const lock = path.join(current.root, '.dist.acplugin.lock'); + await fs.writeFile(lock, `${JSON.stringify({ + schemaVersion: 3, + pid: 99_999_999, + token: '00000000-0000-4000-8000-000000000003', + })}\n`); + /** entered 与 gate 把首次 quarantine rename 固定在可竞争窗口。 */ + let notifyEntered!: () => void; + let continueRecovery!: () => void; + const entered = new Promise((resolve) => { + notifyEntered = resolve; + }); + const gate = new Promise((resolve) => { + continueRecovery = resolve; + }); + /** 真实 rename 仅在 stale lock 路径上注入暂停。 */ + const realRename = fs.rename.bind(fs); + const rename = vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if (String(source) === lock && String(destination).endsWith('.stale')) { + notifyEntered(); + await gate; + } + return realRename(source, destination); + }); + try { + /** 第一个 writer 持有 metadata guard 并暂停在 stale quarantine。 */ + const first = commitPackageUnits(outDir, [], current.assets, { projectRoot: current.root }); + await entered; + /** 第二个 writer 不能移除/替换 final lock,只能在 guard 外失败关闭。 */ + let secondError: unknown; + try { + await commitPackageUnits(outDir, [], current.assets, { projectRoot: current.root }); + } catch (error) { + secondError = error; + } + expect(secondError).toBeInstanceOf(Error); + expect((secondError as Error).message).toContain('locked'); + continueRecovery(); + await first; + } finally { + continueRecovery(); + rename.mockRestore(); + } + expect(await helpers(current.root)).toEqual([]); + }); + + it('recovers backup/record/stale-stage state before starting a new transaction', async () => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + const backup = path.join(current.root, '.dist.acplugin-backup'); + const staleStage = path.join(current.root, '.dist.acplugin-stage-crashed'); + const transaction = path.join(current.root, '.dist.acplugin-transaction.json'); + await fs.mkdir(path.join(backup, 'old', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(backup, 'old', 'plugin', 'version.txt'), 'old'); + await fs.mkdir(staleStage); + await fs.writeFile(path.join(staleStage, 'partial.txt'), 'partial'); + await transactionMarker(current.root, 'transaction', true); + + await expect(commitPackageUnits(outDir, [], current.assets, { + projectRoot: current.root, + onPhase(phase) { + if (phase === 'recovery-complete') + throw new Error('stop after recovery'); + }, + })).rejects.toThrow('stop after recovery'); + + expect(await fs.readFile(path.join(outDir, 'old', 'plugin', 'version.txt'), 'utf8')).toBe('old'); + await expect(fs.access(staleStage)).rejects.toThrow(); + await expect(fs.access(transaction)).rejects.toThrow(); + }); + + it('rolls back an exposed output after a process crash before cleanup committed', async () => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + const backup = path.join(current.root, '.dist.acplugin-backup'); + await fs.mkdir(path.join(outDir, 'target', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'uncommitted-new'); + await fs.mkdir(path.join(backup, 'target', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(backup, 'target', 'plugin', 'version.txt'), 'old'); + await transactionMarker(current.root, 'transaction', true); + + await expect(commitPackageUnits(outDir, [], current.assets, { + projectRoot: current.root, + onPhase(phase) { + if (phase === 'recovery-complete') + throw new Error('stop after rollback recovery'); + }, + })).rejects.toThrow('stop after rollback recovery'); + + expect(await fs.readFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'utf8')).toBe('old'); + expect(await helpers(current.root)).toEqual([]); + }); + + it('removes a first-build output exposed before cleanup committed', async () => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + await fs.mkdir(path.join(outDir, 'target', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'uncommitted-first'); + await transactionMarker(current.root, 'transaction', false); + + await expect(commitPackageUnits(outDir, [], current.assets, { + projectRoot: current.root, + onPhase(phase) { + if (phase === 'recovery-complete') + throw new Error('stop after first-build recovery'); + }, + })).rejects.toThrow('stop after first-build recovery'); + + await expect(fs.access(outDir)).rejects.toThrow(); + expect(await helpers(current.root)).toEqual([]); + }); + + it('keeps a cleanup-committed output and discards its old backup', async () => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + const backup = path.join(current.root, '.dist.acplugin-backup'); + await fs.mkdir(path.join(outDir, 'target', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'committed-new'); + await fs.mkdir(path.join(backup, 'target', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(backup, 'target', 'plugin', 'version.txt'), 'old'); + await transactionMarker(current.root, 'transaction', true); + await transactionMarker(current.root, 'committed', true); + + await expect(commitPackageUnits(outDir, [], current.assets, { + projectRoot: current.root, + onPhase(phase) { + if (phase === 'recovery-complete') + throw new Error('stop after committed recovery'); + }, + })).rejects.toThrow('stop after committed recovery'); + + expect(await fs.readFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'utf8')).toBe('committed-new'); + expect(await helpers(current.root)).toEqual([]); + }); + + it('ignores an unpublished transaction marker draft after a process crash', async () => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + await fs.mkdir(path.join(outDir, 'target', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'old'); + /** 截断草稿模拟进程在首次 marker 原子发布前退出。 */ + await fs.writeFile(path.join(current.root, '.dist.acplugin-transaction.json.writing'), '{"schemaVersion":'); + + await expect(commitPackageUnits(outDir, [], current.assets, { + projectRoot: current.root, + onPhase(phase) { + if (phase === 'recovery-complete') + throw new Error('stop after transaction draft recovery'); + }, + })).rejects.toThrow('stop after transaction draft recovery'); + + expect(await fs.readFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'utf8')).toBe('old'); + expect(await helpers(current.root)).toEqual([]); + }); + + it('rolls back when a process crashes before publishing the committed marker', async () => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + const backup = path.join(current.root, '.dist.acplugin-backup'); + await fs.mkdir(path.join(outDir, 'target', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'uncommitted-new'); + await fs.mkdir(path.join(backup, 'target', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(backup, 'target', 'plugin', 'version.txt'), 'old'); + await transactionMarker(current.root, 'transaction', true); + /** 截断草稿不是权威 committed marker,恢复必须选择 rollback。 */ + await fs.writeFile(path.join(current.root, '.dist.acplugin-committed.json.writing'), '{"schemaVersion":'); + + await expect(commitPackageUnits(outDir, [], current.assets, { + projectRoot: current.root, + onPhase(phase) { + if (phase === 'recovery-complete') + throw new Error('stop after committed draft recovery'); + }, + })).rejects.toThrow('stop after committed draft recovery'); + + expect(await fs.readFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'utf8')).toBe('old'); + expect(await helpers(current.root)).toEqual([]); + }); + + it('removes a dead-process lock and commits successfully', async () => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + const lock = path.join(current.root, '.dist.acplugin.lock'); + await fs.writeFile(lock, `${JSON.stringify({ + schemaVersion: 3, + pid: 99_999_999, + token: '00000000-0000-4000-8000-000000000001', + })}\n`); + + await commitPackageUnits(outDir, [], current.assets, { projectRoot: current.root }); + + expect(await fs.readdir(outDir)).toEqual([]); + await expect(fs.access(lock)).rejects.toThrow(); + }); + + it.each(['', '{"schemaVersion":', '{"schemaVersion":2,"pid":1}\n'])( + 'recovers a malformed atomic lock record %j', + async (contents) => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + const lock = path.join(current.root, '.dist.acplugin.lock'); + await fs.writeFile(lock, contents); + + await commitPackageUnits(outDir, [], current.assets, { projectRoot: current.root }); + + expect(await fs.readdir(outDir)).toEqual([]); + expect(await helpers(current.root)).toEqual([]); + }, + ); + + it('preserves a malformed legacy lock that becomes a live record during the bounded check', async () => { + /** 空文件模拟旧 create→write writer 尚未完成的中间状态。 */ + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + const lock = path.join(current.root, '.dist.acplugin.lock'); + await fs.writeFile(lock, ''); + /** stability window 内把同一路径补全为当前可见父进程持有的活锁。 */ + const liveRecord = `${JSON.stringify({ + schemaVersion: 3, + pid: process.ppid, + token: '00000000-0000-4000-8000-000000000004', + })}\n`; + const writer = new Promise((resolve, reject) => { + setTimeout(() => { + fs.writeFile(lock, liveRecord).then(() => resolve(), reject); + }, 5); + }); + + await expect(commitPackageUnits(outDir, [], current.assets, { projectRoot: current.root })).rejects.toThrow('locked'); + await writer; + expect(await fs.readFile(lock, 'utf8')).toBe(liveRecord); + /** 测试清理只移除模拟的外部 live lock。 */ + await fs.rm(lock); + expect(await helpers(current.root)).toEqual([]); + }); + + it('rejects a same-byte lock replacement by comparing stable metadata', async () => { + /** 两个空文件字节相同,只有 inode/metadata 能证明路径已被替换。 */ + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + const lock = path.join(current.root, '.dist.acplugin.lock'); + const displaced = `${lock}.external`; + await fs.writeFile(lock, ''); + /** quarantine rename 前用同字节新 inode 替换 lock,模拟不参与 guard 的外部 writer。 */ + const realRename = fs.rename.bind(fs); + let injected = false; + const rename = vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if (!injected && String(source) === lock && String(destination).endsWith('.stale')) { + injected = true; + await realRename(lock, displaced); + await fs.writeFile(lock, ''); + } + await realRename(source, destination); + }); + + try { + await expect(commitPackageUnits(outDir, [], current.assets, { projectRoot: current.root })).rejects.toThrow('locked'); + } finally { + rename.mockRestore(); + } + expect(injected).toBe(true); + expect(await fs.readFile(lock, 'utf8')).toBe(''); + expect(await fs.readFile(displaced, 'utf8')).toBe(''); + /** 两个模拟外部路径都不属于当前事务,测试结束前显式清理。 */ + await fs.rm(lock); + await fs.rm(displaced); + expect(await helpers(current.root)).toEqual([]); + }); + + it('recovers a stale token left by the current process', async () => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + const lock = path.join(current.root, '.dist.acplugin.lock'); + await fs.writeFile(lock, `${JSON.stringify({ + schemaVersion: 3, + pid: process.pid, + token: '00000000-0000-4000-8000-000000000002', + })}\n`); + + await commitPackageUnits(outDir, [], current.assets, { projectRoot: current.root }); + + expect(await helpers(current.root)).toEqual([]); + }); + + it('rolls back the complete old set when afterSwap cleanup fails', async () => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + await fs.mkdir(path.join(outDir, 'target', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'old'); + + await expect(commitPackageUnits(outDir, [await unit(current.assets, 'target', 'new')], current.assets, { + projectRoot: current.root, + afterSwap() { + throw new Error('close failed'); + }, + })).rejects.toThrow('close failed'); + + expect(await fs.readFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'utf8')).toBe('old'); + expect(await helpers(current.root)).toEqual([]); + }); +}); diff --git a/packages/core/test/package/candidate-materializer.test.ts b/packages/core/test/package/candidate-materializer.test.ts new file mode 100644 index 0000000..544e610 --- /dev/null +++ b/packages/core/test/package/candidate-materializer.test.ts @@ -0,0 +1,127 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { PackageUnitSnapshot } from '../../src/contracts/index.js'; +import { AssetRegistry } from '../../src/services/assets.js'; +import { BuildSessionScope } from '../../src/services/session-scope.js'; +import { SourceRegistry } from '../../src/services/sources.js'; +import { WorkDirectoryRegistry } from '../../src/services/work-directories.js'; +import { materializePackageCandidate, withPackageCandidate } from '../../src/package/candidate-materializer.js'; + +/** Candidate 测试使用并统一清理的临时根。 */ +const roots: string[] = []; + +/** @returns 当前测试独占的 Registry 与临时根。 */ +async function fixture() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-candidate-v2-')); + roots.push(root); + const scope = new BuildSessionScope(); + const sources = new SourceRegistry(scope, root); + const work = new WorkDirectoryRegistry(scope, path.join(root, '.work')); + const assets = new AssetRegistry(scope, sources, work); + return { root, scope, sources, work, assets }; +} + +/** @returns 带一个 executable Asset 的最小主 Package Unit。 */ +async function unit(assets: AssetRegistry): Promise { + /** Platform owner 签发的 candidate 内容。 */ + const asset = await assets.service('platform:target').fromBytes({ + bytes: 'export default true;\n', mode: 0o755, origin: { operation: 'runtime-main' }, + }); + return Object.freeze({ + platform: 'target', id: 'plugin', type: 'plugin', role: 'primary', + assets: Object.freeze([{ path: 'runtime/main.mjs', owner: 'platform:target', asset }]), + compatibility: Object.freeze([]), metadata: Object.freeze([]), + }); +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('Package candidate materializer', () => { + it('materializes a complete candidate and always cleans its temporary root', async () => { + const current = await fixture(); + const packageUnit = await unit(current.assets); + let candidateRoot = ''; + + await withPackageCandidate(packageUnit, current.assets, async (candidate) => { + candidateRoot = candidate.root; + expect(await fs.readFile(path.join(candidate.root, 'runtime', 'main.mjs'), 'utf8')).toBe('export default true;\n'); + expect((await fs.stat(path.join(candidate.root, 'runtime', 'main.mjs'))).mode & 0o777).toBe(0o755); + if (process.platform !== 'win32') { + expect((await fs.stat(candidate.root)).mode & 0o777).toBe(0o700); + expect((await fs.stat(path.join(candidate.root, 'runtime'))).mode & 0o777).toBe(0o700); + } + }, current.root); + + await expect(fs.access(candidateRoot)).rejects.toThrow(); + }); + + it('detects validator byte, mode, extra-file, symlink and empty-directory mutations', async () => { + const mutations = [ + async (root: string) => fs.writeFile(path.join(root, 'runtime', 'main.mjs'), 'mutated'), + async (root: string) => fs.chmod(path.join(root, 'runtime', 'main.mjs'), 0o644), + async (root: string) => fs.writeFile(path.join(root, 'extra.txt'), 'extra'), + async (root: string) => fs.symlink(path.join(root, 'runtime', 'main.mjs'), path.join(root, 'link.mjs')), + async (root: string) => fs.mkdir(path.join(root, 'empty')), + ]; + for (const mutate of mutations) { + const current = await fixture(); + const packageUnit = await unit(current.assets); + await expect(withPackageCandidate(packageUnit, current.assets, async (candidate) => { + await mutate(candidate.root); + }, current.root)).rejects.toThrow(/(?:integrity|mode|closure|symbolic link)/u); + } + }); + + it('rejects forged, cross-owner and colliding Package Asset snapshots', async () => { + const current = await fixture(); + const original = await unit(current.assets); + const mapping = original.assets[0]!; + /** 等形复制不能替代 AssetRegistry 中的原始 ref identity。 */ + const forged = Object.freeze({ ...mapping.asset }); + await expect(materializePackageCandidate(Object.freeze({ + ...original, assets: Object.freeze([{ ...mapping, asset: forged }]), + }), current.assets, current.root)).rejects.toThrow('not authorized'); + /** mapping owner 必须与真实 issuer 一致。 */ + await expect(materializePackageCandidate(Object.freeze({ + ...original, assets: Object.freeze([{ ...mapping, owner: 'extension:forged' }]), + }), current.assets, current.root)).rejects.toThrow('owner mismatch'); + /** 文件路径不能同时作为另一个文件的祖先目录。 */ + await expect(materializePackageCandidate(Object.freeze({ + ...original, + assets: Object.freeze([ + mapping, + { ...mapping, path: 'runtime' }, + ]), + }), current.assets, current.root)).rejects.toThrow('collides'); + }); + + it('rejects SourceAsset mutation immediately before candidate materialization', async () => { + const current = await fixture(); + const sourceRoot = path.join(current.root, 'public'); + await fs.mkdir(sourceRoot); + await fs.writeFile(path.join(sourceRoot, 'data.txt'), 'original'); + const directory = await current.sources.issueRoot('framework:public', sourceRoot); + const source = await current.sources.service('framework:public').file(directory, 'data.txt'); + const asset = await current.assets.service('framework:public').fromSource(source); + current.assets.grant('framework:public', 'platform:target', asset); + const packageUnit: PackageUnitSnapshot = Object.freeze({ + platform: 'target', id: 'plugin', type: 'plugin', role: 'primary', + assets: Object.freeze([{ path: 'data.txt', owner: 'framework:public', asset }]), + compatibility: Object.freeze([]), metadata: Object.freeze([]), + }); + await fs.writeFile(path.join(sourceRoot, 'data.txt'), 'changed'); + + await expect(materializePackageCandidate(packageUnit, current.assets, current.root)).rejects.toThrow('changed after'); + }); + + it('makes candidate cleanup idempotent', async () => { + const current = await fixture(); + const handle = await materializePackageCandidate(await unit(current.assets), current.assets, current.root); + await handle.cleanup(); + await expect(handle.cleanup()).resolves.toBeUndefined(); + }); +}); diff --git a/packages/core/test/package/compatibility.test.ts b/packages/core/test/package/compatibility.test.ts new file mode 100644 index 0000000..70785cb --- /dev/null +++ b/packages/core/test/package/compatibility.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; +import type { CanonicalProject } from '../../src/contracts/index.js'; +import { DiagnosticRegistry } from '../../src/services/diagnostics.js'; +import { CompatibilityRegistry, compatibilityTupleKey } from '../../src/package/compatibility.js'; + +/** @returns 带 Command → Skill 依赖和可选 metadata 的 Project。 */ +function project(): CanonicalProject { + const requires = (skills: readonly string[] = []) => Object.freeze({ skills: Object.freeze(skills), agents: Object.freeze([]) }); + return Object.freeze({ + metadata: Object.freeze({ + name: 'compatibility', version: '1.0.0', description: 'Compatibility.', displayName: 'Compatibility', + author: Object.freeze({ name: 'TokenRoll' }), keywords: Object.freeze([]), + }), + commands: Object.freeze([{ + kind: 'command' as const, id: 'check', description: 'Check.', body: 'Check.', location: { path: 'src/commands/check.md', bodyLine: 4 }, + requires: requires(['review']), platforms: Object.freeze({}), + }]), + skills: Object.freeze([{ + kind: 'skill' as const, id: 'review', description: 'Review.', body: 'Review.', invocation: { user: true, model: true }, + location: { path: 'src/skills/review/SKILL.md', bodyLine: 4 }, requires: requires(), platforms: Object.freeze({}), auxiliaryFiles: Object.freeze([]), + }]), + agents: Object.freeze([]), + publicFiles: Object.freeze([]), + }); +} + +/** @returns 覆盖当前 Project 实际 metadata 字段的输入。 */ +function metadata() { + return ['name', 'version', 'description', 'displayName', 'author.name'].map(field => ({ + field, disposition: 'emitted' as const, output: `manifest.${field}`, reason: 'Emitted.', + })); +} + +describe('Compatibility Registry', () => { + it('propagates dependency degradation and enforces strictness once on the final graph', () => { + const diagnostics = new DiagnosticRegistry(); + const registry = new CompatibilityRegistry({ project: project(), diagnostics }); + registry.addCompatibility('target', [ + { subject: 'command:check', capability: 'component', level: 'native', reason: 'Native.' }, + { subject: 'skill:review', capability: 'component', level: 'unsupported', reason: 'Unavailable.' }, + ]); + registry.addMetadata('target', metadata()); + const result = registry.finalize([{ id: 'target', strict: true }]); + + expect(result.compatibility.find(entry => entry.subject === 'command:check')).toMatchObject({ + level: 'unsupported', causes: [compatibilityTupleKey('skill:review', 'component')], + }); + expect(diagnostics.diagnostics.filter(item => item.code === 'COMPATIBILITY_STRICT_FAILURE')).toHaveLength(2); + }); + + it('reports exact Component/metadata coverage and relaxed warnings', () => { + const diagnostics = new DiagnosticRegistry(); + const registry = new CompatibilityRegistry({ project: project(), diagnostics }); + registry.addCompatibility('target', [ + { subject: 'command:check', capability: 'component', level: 'degraded', reason: 'UI discoverability differs.' }, + ]); + registry.addMetadata('target', [ + { field: 'name', disposition: 'emitted', output: 'manifest.name', reason: 'Emitted.' }, + { field: 'homepage', disposition: 'omitted', reason: 'Absent.' }, + ]); + registry.finalize([{ id: 'target', strict: false }]); + + expect(diagnostics.diagnostics.map(item => item.code)).toEqual(expect.arrayContaining([ + 'COMPATIBILITY_COMPONENT_MISSING', 'METADATA_DISPOSITION_MISSING', + 'METADATA_DISPOSITION_UNUSED', 'COMPATIBILITY_RELAXED', + ])); + expect(diagnostics.diagnostics.find(item => item.code === 'COMPATIBILITY_RELAXED')?.severity).toBe('warning'); + }); + + it('rejects duplicate tuples and missing, self or cyclic causes', () => { + const diagnostics = new DiagnosticRegistry(); + const duplicate = new CompatibilityRegistry({ project: project(), diagnostics }); + duplicate.addCompatibility('target', [{ subject: 'skill:review', capability: 'component', level: 'native', reason: 'Native.' }]); + expect(() => duplicate.addCompatibility('target', [{ subject: 'skill:review', capability: 'component', level: 'native', reason: 'Native.' }])).toThrow('duplicated'); + + const missing = new CompatibilityRegistry({ project: project(), diagnostics: new DiagnosticRegistry() }); + missing.addCompatibility('target', [ + { subject: 'skill:review', capability: 'component', level: 'native', reason: 'Native.', causes: ['agent:missing#component'] }, + { subject: 'command:check', capability: 'component', level: 'native', reason: 'Native.' }, + ]); + missing.addMetadata('target', metadata()); + expect(() => missing.finalize([{ id: 'target', strict: true }])).toThrow('missing or self'); + + const cyclic = new CompatibilityRegistry({ project: project(), diagnostics: new DiagnosticRegistry() }); + cyclic.addCompatibility('target', [ + { subject: 'skill:review', capability: 'component', level: 'native', reason: 'Native.', causes: ['command:check#component'] }, + { subject: 'command:check', capability: 'component', level: 'native', reason: 'Native.', causes: ['skill:review#component'] }, + ]); + cyclic.addMetadata('target', metadata()); + expect(() => cyclic.finalize([{ id: 'target', strict: true }])).toThrow('cycle'); + }); + + it('rejects compatibility and metadata accessors, classes, Symbols and mutable nested inputs', () => { + const diagnostics = new DiagnosticRegistry(); + const registry = new CompatibilityRegistry({ project: project(), diagnostics }); + class Entry {} + const accessor = Object.defineProperty({}, 'subject', { get: () => 'skill:review', enumerable: true }); + expect(() => registry.addCompatibility('target', [new Entry() as never])).toThrow('plain object'); + expect(() => registry.addCompatibility('target', [accessor as never])).toThrow('data property'); + expect(() => registry.addCompatibility('target', [{ + subject: 'skill:review', capability: 'component', level: 'native', reason: 'Native.', [Symbol('hidden')]: true, + } as never])).toThrow('Symbol'); + expect(() => registry.addMetadata('target', [Object.defineProperty({}, 'field', { + get: () => 'name', enumerable: true, + }) as never])).toThrow('data property'); + + const causes = ['command:check#component']; + registry.addCompatibility('target', [{ + subject: 'skill:review', capability: 'component', level: 'native', reason: 'Native.', causes, + }]); + causes[0] = 'agent:mutated#component'; + registry.addCompatibility('target', [{ subject: 'command:check', capability: 'component', level: 'native', reason: 'Native.' }]); + registry.addMetadata('target', metadata()); + expect(registry.finalize([{ id: 'target', strict: true }]).compatibility[1]?.causes).toEqual(['command:check#component']); + }); + + it('sanitizes compatibility and diagnostic free text without reading environment values', () => { + const diagnostics = new DiagnosticRegistry(); + diagnostics.report('package', { + code: 'UNSAFE_TEXT', severity: 'error', message: 'Bearer top-secret failed at /Users/example/private/file.ts\nnext', + }); + expect(diagnostics.diagnostics[0]?.message).toBe(' failed at next'); + }); +}); diff --git a/packages/core/test/package/distribution-registry.test.ts b/packages/core/test/package/distribution-registry.test.ts new file mode 100644 index 0000000..f06db9b --- /dev/null +++ b/packages/core/test/package/distribution-registry.test.ts @@ -0,0 +1,124 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { AssetRef, PackageUnitSnapshot } from '../../src/contracts/index.js'; +import { AssetRegistry } from '../../src/services/assets.js'; +import { BuildSessionScope } from '../../src/services/session-scope.js'; +import { SourceRegistry } from '../../src/services/sources.js'; +import { WorkDirectoryRegistry } from '../../src/services/work-directories.js'; +import { collectDistributionPackages, createDistributionPackage } from '../../src/package/distributions.js'; + +/** Distribution 测试统一清理的临时根。 */ +const roots: string[] = []; + +/** @returns Asset Registry 与带继承 Asset 的 primary Unit。 */ +async function fixture() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-distribution-v2-')); + roots.push(root); + const scope = new BuildSessionScope(); + const sources = new SourceRegistry(scope, root); + const work = new WorkDirectoryRegistry(scope, path.join(root, '.work')); + const assets = new AssetRegistry(scope, sources, work); + const inherited = await assets.service('platform:target').fromBytes({ + bytes: 'plugin', mode: 0o755, origin: { operation: 'plugin-main' }, + }); + const primary: PackageUnitSnapshot = Object.freeze({ + platform: 'target', id: 'plugin', type: 'plugin', role: 'primary', + assets: Object.freeze([{ path: 'main.mjs', owner: 'platform:target', asset: inherited }]), + compatibility: Object.freeze([]), metadata: Object.freeze([]), + }); + return { root, assets, primary, inherited }; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('Distribution Package Registry', () => { + it('inherits primary refs, accepts callback-issued refs and preserves metadata', async () => { + const current = await fixture(); + const scope = current.assets.issuanceScope('platform:target'); + const manifest = await scope.service.fromBytes({ bytes: '{}\n', origin: { operation: 'marketplace-manifest' } }); + scope.close(); + const distribution = createDistributionPackage({ + platform: 'target', primary: current.primary, assets: current.assets, issued: scope.includes, + input: { + id: 'marketplace', type: 'marketplace', + assets: [ + { path: 'plugin/runtime.mjs', asset: current.inherited }, + { path: 'marketplace.json', asset: manifest }, + ], + }, + }); + + expect(distribution).toMatchObject({ platform: 'target', id: 'marketplace', role: 'distribution', type: 'marketplace' }); + expect(distribution.assets.map(asset => [asset.path, asset.owner])).toEqual([ + ['marketplace.json', 'platform:target'], + ['plugin/runtime.mjs', 'platform:target'], + ]); + await expect(scope.service.fromBytes({ bytes: 'late', origin: { operation: 'late' } })).rejects.toThrow('no longer active'); + }); + + it('rejects foreign granted refs, forged refs, duplicate roots and invalid identities', async () => { + const current = await fixture(); + const foreign = await current.assets.service('extension:foreign').fromBytes({ bytes: 'foreign', origin: { operation: 'foreign' } }); + current.assets.grant('extension:foreign', 'platform:target', foreign); + const scope = current.assets.issuanceScope('platform:target'); + scope.close(); + const create = (asset: AssetRef, id = 'marketplace') => createDistributionPackage({ + platform: 'target', primary: current.primary, assets: current.assets, issued: scope.includes, + input: { id, type: 'marketplace', assets: [{ path: 'foreign.txt', asset }] }, + }); + + expect(() => create(foreign)).toThrow('inherited from primary or issued'); + expect(() => create(Object.freeze({ ...current.inherited }) as AssetRef)).toThrow('inherited from primary or issued'); + expect(() => create(current.inherited, 'plugin')).toThrow('differ from the primary'); + expect(() => createDistributionPackage({ + platform: 'target', primary: current.primary, assets: current.assets, issued: scope.includes, + input: { + id: 'marketplace', type: 'marketplace', + assets: [ + { path: 'tree', asset: current.inherited }, + { path: 'tree/main.mjs', asset: current.inherited }, + ], + }, + })).toThrow('collides'); + }); + + it('owns the callback Asset scope, closes leaked services and rejects duplicate outputs', async () => { + const current = await fixture(); + let leaked: Parameters[0]['create']>[0] | undefined; + const distributions = await collectDistributionPackages({ + platform: 'target', primary: current.primary, assets: current.assets, + async create(assets) { + leaked = assets; + const manifest = await assets.fromBytes({ bytes: '{}\n', origin: { operation: 'marketplace-manifest' } }); + return [{ id: 'marketplace', type: 'marketplace', assets: [{ path: 'marketplace.json', asset: manifest }] }]; + }, + }); + + expect(distributions.map(unit => unit.id)).toEqual(['marketplace']); + await expect(leaked!.fromBytes({ bytes: 'late', origin: { operation: 'late' } })).rejects.toThrow('no longer active'); + await expect(collectDistributionPackages({ + platform: 'target', primary: current.primary, assets: current.assets, + create: () => [ + { id: 'marketplace', type: 'marketplace', assets: [] }, + { id: 'marketplace', type: 'marketplace', assets: [] }, + ], + })).rejects.toThrow('unique'); + }); + + it('closes the callback Asset scope when Platform creation throws', async () => { + const current = await fixture(); + let leaked: Parameters[0]['create']>[0] | undefined; + await expect(collectDistributionPackages({ + platform: 'target', primary: current.primary, assets: current.assets, + create(assets) { + leaked = assets; + throw new Error('Platform failed'); + }, + })).rejects.toThrow('Platform failed'); + await expect(leaked!.fromBytes({ bytes: 'late', origin: { operation: 'late' } })).rejects.toThrow('no longer active'); + }); +}); diff --git a/packages/core/test/package/document-codec.test.ts b/packages/core/test/package/document-codec.test.ts new file mode 100644 index 0000000..2dd9af1 --- /dev/null +++ b/packages/core/test/package/document-codec.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import type { PackageDocumentSnapshot } from '../../src/contracts/index.js'; +import { encodePackageDocument } from '../../src/package/documents.js'; + +/** @returns 指定格式和值的最小冻结 Document snapshot。 */ +function document(format: PackageDocumentSnapshot['format'], value: PackageDocumentSnapshot['value']): PackageDocumentSnapshot { + return Object.freeze({ + id: 'manifest', + path: `manifest.${format}`, + format, + value, + emission: 'required', + extensionPoints: Object.freeze([]), + finalizationPoints: Object.freeze([]), + }); +} + +/** @returns codec 字节的 UTF-8 文本。 */ +function text(snapshot: PackageDocumentSnapshot): string { + return new TextDecoder().decode(encodePackageDocument(snapshot)); +} + +describe('Core Package Document codec', () => { + it('produces stable JSON, YAML, TOML and frontmatter golden bytes', () => { + const value = { z: 2, a: { enabled: true } }; + expect(text(document('json', value))).toBe(`{ + "a": { + "enabled": true + }, + "z": 2 +} +`); + expect(text(document('yaml', value))).toBe('a:\n enabled: true\nz: 2\n'); + expect(text(document('toml', value))).toBe('z = 2\n\n[a]\nenabled = true\n'); + expect(text(document('frontmatter', { + frontmatter: { z: 2, a: 'value' }, + body: ' Body. ', + }))).toBe('---\na: value\nz: 2\n---\nBody.\n'); + }); + + it('serializes TOML strings, arrays and nested tables deterministically', () => { + const value = { + title: 'Needs "quotes" and a newline\n', + values: ['first', 2, true], + nested: { + 'a.b': 'quoted key', + 'ratio': 1.5, + 'zero': 0, + }, + }; + const first = text(document('toml', value)); + const second = text(document('toml', value)); + + expect(first).toBe('title = "Needs \\"quotes\\" and a newline\\n"\nvalues = [ "first", 2, true ]\n\n[nested]\n"a.b" = "quoted key"\nratio = 1.5\nzero = 0\n'); + expect(second).toBe(first); + }); + + it('rejects unsupported roots and malformed frontmatter without lossy coercion', () => { + expect(() => text(document('toml', ['not', 'an', 'object']))).toThrow('TOML Document root'); + expect(() => text(document('frontmatter', { frontmatter: {}, body: 'Body.', extra: true }))).toThrow('exactly'); + expect(() => text(document('frontmatter', { frontmatter: [], body: 'Body.' }))).toThrow('JSON object'); + }); +}); diff --git a/packages/core/test/package/package-registry.test.ts b/packages/core/test/package/package-registry.test.ts new file mode 100644 index 0000000..4ca657e --- /dev/null +++ b/packages/core/test/package/package-registry.test.ts @@ -0,0 +1,524 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { PackageContribution, PlatformPackageInput } from '../../src/contracts/index.js'; +import { AssetRegistry } from '../../src/services/assets.js'; +import { BuildSessionScope } from '../../src/services/session-scope.js'; +import { SourceRegistry } from '../../src/services/sources.js'; +import { WorkDirectoryRegistry } from '../../src/services/work-directories.js'; +import { + createBasePackage, + finalizePrimaryPackage, + mergePackageContributions, +} from '../../src/package/registry.js'; + +/** Package Registry 测试临时根。 */ +const roots: string[] = []; + +/** @returns 当前 Session Asset Registry 与 owner services。 */ +async function fixture() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-package-registry-')); + roots.push(root); + const scope = new BuildSessionScope(); + const sources = new SourceRegistry(scope, root); + const work = new WorkDirectoryRegistry(scope, path.join(root, '.work')); + const assets = new AssetRegistry(scope, sources, work); + return { root, assets, sources }; +} + +/** @returns 另一 BuildSession,用于证明 ref identity 不跨 Session。 */ +async function otherAssets(root: string): Promise { + const scope = new BuildSessionScope(); + const sources = new SourceRegistry(scope, root); + const work = new WorkDirectoryRegistry(scope, path.join(root, '.work-other')); + return new AssetRegistry(scope, sources, work); +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +/** @returns 带两个 extension point 的 Platform base Package。 */ +async function baseInput(assets: AssetRegistry): Promise { + const readme = await assets.service('platform:target').fromBytes({ + bytes: 'readme\n', + origin: { operation: 'platform-readme' }, + }); + return { + documents: [{ + id: 'manifest', + path: 'plugin.json', + format: 'json', + value: { extensions: {} }, + extensionPoints: [['extensions', 'hooks'], ['extensions', 'mcp']], + }], + assets: [{ path: 'README.md', asset: readme }], + compatibility: [{ subject: 'skill:review', capability: 'component', level: 'native', reason: 'Native.' }], + metadata: [ + { field: 'name', disposition: 'emitted', output: 'manifest.name', reason: 'Emitted.' }, + { field: 'version', disposition: 'emitted', output: 'manifest.version', reason: 'Emitted.' }, + { field: 'description', disposition: 'emitted', output: 'manifest.description', reason: 'Emitted.' }, + ], + }; +} + +describe('Package Registry', () => { + it('creates immutable base snapshots and rejects occupied or colliding Documents', async () => { + const current = await fixture(); + const input = await baseInput(current.assets); + const base = createBasePackage('target', input, current.assets); + + expect(base.documents[0]).toMatchObject({ id: 'manifest', path: 'plugin.json', emission: 'required' }); + expect(Object.isFrozen(base)).toBe(true); + expect(Object.isFrozen(base.documents[0]?.value)).toBe(true); + expect(() => createBasePackage('target', { + ...input, + documents: [{ ...input.documents[0]!, value: { extensions: { hooks: true } }, extensionPoints: [['extensions', 'hooks']] }], + }, current.assets)).toThrow('empty field'); + expect(() => createBasePackage('target', { + ...input, + documents: [{ ...input.documents[0]!, value: { extensions: { agents: true } }, finalizationPoints: [['extensions', 'agents']] }], + }, current.assets)).toThrow('empty field'); + expect(() => createBasePackage('target', { + ...input, + documents: [{ + ...input.documents[0]!, + finalizationPoints: [['extensions', 'agents'], ['extensions', 'agents']], + }], + }, current.assets)).toThrow('finalization point ["extensions","agents"] is duplicated'); + expect(() => createBasePackage('target', { + ...input, + documents: [...input.documents, { ...input.documents[0]!, id: 'other', path: 'PLUGIN.json', extensionPoints: [] }], + }, current.assets)).toThrow('collides'); + /** null-prototype JSON records are valid data containers at the package boundary. */ + const nullPrototype = Object.assign(Object.create(null) as Record, { + extensions: Object.create(null) as Record, + }); + expect(createBasePackage('target', { + ...input, + documents: [{ ...input.documents[0]!, value: nullPrototype as never }], + }, current.assets).documents[0]?.value).toEqual({ extensions: {} }); + }); + + it('merges contributions independently of configuration/completion order', async () => { + const current = await fixture(); + const base = createBasePackage('target', await baseInput(current.assets), current.assets); + const hooksAsset = await current.assets.service('extension:hooks').fromBytes({ + bytes: 'hooks', origin: { operation: 'hooks-runtime', subjects: ['hook:pre-tool'] }, + }); + const mcpAsset = await current.assets.service('extension:mcp').fromBytes({ + bytes: 'mcp', origin: { operation: 'mcp-runtime', subjects: ['mcp:tools'] }, + }); + const contributions = [ + { + owner: 'extension:mcp', + subjects: [{ subject: 'mcp:tools', capabilities: ['runtime'] }], + contribution: { + documentFields: [{ document: 'manifest', path: ['extensions', 'mcp'], value: { enabled: true } }], + assets: [{ path: 'runtime/mcp.mjs', asset: mcpAsset }], + compatibility: [{ subject: 'mcp:tools', capability: 'runtime', level: 'native', reason: 'Native.' }], + }, + }, + { + owner: 'extension:hooks', + subjects: [{ subject: 'hook:pre-tool', capabilities: ['runtime'] }], + contribution: { + documentFields: [{ document: 'manifest', path: ['extensions', 'hooks'], value: { enabled: true } }], + assets: [{ path: 'runtime/hooks.mjs', asset: hooksAsset }], + compatibility: [{ subject: 'hook:pre-tool', capability: 'runtime', level: 'native', reason: 'Native.' }], + }, + }, + ] as const; + + const first = mergePackageContributions('target', base, contributions, current.assets); + const second = mergePackageContributions('target', base, [...contributions].reverse(), current.assets); + expect(first.documents[0]?.value).toEqual(second.documents[0]?.value); + expect(first.assets.map(asset => [asset.path, asset.owner])).toEqual(second.assets.map(asset => [asset.path, asset.owner])); + expect(first.assets.map(asset => asset.path)).toEqual(['README.md', 'runtime/hooks.mjs', 'runtime/mcp.mjs']); + }); + + it('copies opaque JSON Components, binds them to validated subjects and sorts independently of input order', async () => { + const current = await fixture(); + const base = createBasePackage('target', await baseInput(current.assets), current.assets); + const contributions = [ + { + owner: 'extension:zeta', + subjects: [{ subject: 'private:zeta', capabilities: ['component'] }], + contribution: { + components: [ + { subject: 'private:zeta', value: { opaque: { second: true, first: 1 } } }, + { subject: 'private:zeta', value: { opaque: { first: 0 } } }, + ], + compatibility: [{ subject: 'private:zeta', capability: 'component', level: 'native', reason: 'Native.' }], + }, + }, + { + owner: 'extension:alpha', + subjects: [{ subject: 'private:alpha', capabilities: ['component'] }], + contribution: { + components: [{ subject: 'private:alpha', value: { unknownPlatformField: ['kept', 'opaque'] } }], + compatibility: [{ subject: 'private:alpha', capability: 'component', level: 'native', reason: 'Native.' }], + }, + }, + ] as const; + + const first = mergePackageContributions('target', base, contributions, current.assets); + const second = mergePackageContributions('target', base, contributions.map(item => ({ + ...item, + contribution: { + ...item.contribution, + ...(item.contribution.components === undefined ? {} : { components: [...item.contribution.components].reverse() }), + }, + })).reverse(), current.assets); + + expect(first.components.map(component => [component.origin.owner, component.origin.subject, component.value])).toEqual([ + ['extension:alpha', 'private:alpha', { unknownPlatformField: ['kept', 'opaque'] }], + ['extension:zeta', 'private:zeta', { opaque: { first: 0 } }], + ['extension:zeta', 'private:zeta', { opaque: { first: 1, second: true } }], + ]); + expect(second.components.map(component => [component.origin.owner, component.origin.subject, component.value])) + .toEqual(first.components.map(component => [component.origin.owner, component.origin.subject, component.value])); + expect(Object.isFrozen(first.components[0]?.value)).toBe(true); + expect(Object.isFrozen((first.components[2]?.value.opaque) as object)).toBe(true); + }); + + it('keeps async contributor completion order outside centralized merge semantics', async () => { + const current = await fixture(); + const base = createBasePackage('target', await baseInput(current.assets), current.assets); + /** 每个异步 producer 只返回 owner-bound Contribution,不观察其他 producer。 */ + const produce = async (owner: 'extension:hooks' | 'extension:mcp', delay: number) => { + await new Promise(resolve => setTimeout(resolve, delay)); + const id = owner.slice('extension:'.length); + const asset = await current.assets.service(owner).fromBytes({ bytes: id, origin: { operation: `${id}-runtime` } }); + return { + owner, + subjects: [{ subject: `private:${id}`, capabilities: ['component'] }], + contribution: { + components: [{ subject: `private:${id}`, value: { id, enabled: true } }], + documentFields: [{ document: 'manifest', path: ['extensions', id], value: { enabled: true } }], + assets: [{ path: `runtime/${id}.mjs`, asset }], + compatibility: [{ + subject: `private:${id}`, capability: 'component', level: 'native', reason: 'Native.', + }], + }, + } as const; + }; + const hooksFirst = await Promise.all([produce('extension:hooks', 0), produce('extension:mcp', 10)]); + const mcpFirst = await Promise.all([produce('extension:mcp', 0), produce('extension:hooks', 10)]); + + const first = mergePackageContributions('target', base, hooksFirst, current.assets); + const second = mergePackageContributions('target', base, mcpFirst, current.assets); + expect(first.documents).toEqual(second.documents); + expect(first.assets.map(asset => [asset.path, asset.owner])).toEqual(second.assets.map(asset => [asset.path, asset.owner])); + expect(first.components.map(component => [component.origin.owner, component.origin.subject, component.value])) + .toEqual(second.components.map(component => [component.origin.owner, component.origin.subject, component.value])); + }); + + it('rejects undeclared/duplicate fields, path collisions, forged refs and missing subject coverage', async () => { + const current = await fixture(); + const base = createBasePackage('target', await baseInput(current.assets), current.assets); + const hooksAsset = await current.assets.service('extension:hooks').fromBytes({ bytes: 'hooks', origin: { operation: 'hooks-runtime' } }); + const contribution = (value: Partial): PackageContribution => ({ compatibility: [], ...value }); + + expect(() => mergePackageContributions('target', base, [{ + owner: 'extension:hooks', contribution: contribution({ documentFields: [{ document: 'manifest', path: ['extensions', 'unknown'], value: true }] }), + }], current.assets)).toThrow('undeclared'); + expect(() => mergePackageContributions('target', base, [ + { owner: 'extension:a', contribution: contribution({ documentFields: [{ document: 'manifest', path: ['extensions', 'hooks'], value: true }] }) }, + { owner: 'extension:b', contribution: contribution({ documentFields: [{ document: 'manifest', path: ['extensions', 'hooks'], value: false }] }) }, + ], current.assets)).toThrow('claimed by both'); + expect(() => mergePackageContributions('target', base, [{ + owner: 'extension:hooks', contribution: contribution({ assets: [{ path: 'readme.md', asset: hooksAsset }] }), + }], current.assets)).toThrow('collides'); + expect(() => mergePackageContributions('target', base, [{ + owner: 'extension:other', contribution: contribution({ assets: [{ path: 'runtime/hooks.mjs', asset: hooksAsset }] }), + }], current.assets)).toThrow('not authorized'); + expect(() => mergePackageContributions('target', base, [{ + owner: 'extension:hooks', subjects: [{ subject: 'hook:pre-tool', capabilities: ['runtime'] }], contribution: contribution({}), + }], current.assets)).toThrow('does not cover'); + expect(() => mergePackageContributions('target', base, [{ + owner: 'extension:hooks', contribution: contribution({ components: [{ subject: 'hook:pre-tool', value: {} as never }] }), + }], current.assets)).toThrow('without validated Extension subjects'); + expect(() => mergePackageContributions('target', base, [{ + owner: 'extension:hooks', subjects: [{ subject: 'hook:pre-tool', capabilities: ['runtime'] }], + contribution: { + components: [{ subject: 'unknown:subject', value: {} }], + compatibility: [{ subject: 'hook:pre-tool', capability: 'runtime', level: 'native', reason: 'Native.' }], + }, + }], current.assets)).toThrow('was not declared'); + expect(() => mergePackageContributions('target', base, [{ + owner: 'extension:hooks', subjects: [{ subject: 'hook:pre-tool', capabilities: ['runtime'] }], + contribution: { + components: [{ subject: 'hook:pre-tool', value: [] as never }], + compatibility: [{ subject: 'hook:pre-tool', capability: 'runtime', level: 'native', reason: 'Native.' }], + }, + }], current.assets)).toThrow('must be a JSON object'); + /** forged ref 与另一 BuildSession 的真实 ref 都不能进入当前 Package。 */ + expect(() => mergePackageContributions('target', base, [{ + owner: 'extension:hooks', contribution: contribution({ assets: [{ path: 'runtime/forged.mjs', asset: Object.freeze({ ...hooksAsset }) }] }), + }], current.assets)).toThrow('not authorized'); + const other = await otherAssets(current.root); + const crossSession = await other.service('extension:hooks').fromBytes({ bytes: 'other', origin: { operation: 'other-runtime' } }); + expect(() => mergePackageContributions('target', base, [{ + owner: 'extension:hooks', contribution: contribution({ assets: [{ path: 'runtime/other.mjs', asset: crossSession }] }), + }], current.assets)).toThrow('BuildSession'); + }); + + it('rejects behavior-bearing package, contribution and compatibility objects', async () => { + const current = await fixture(); + const input = await baseInput(current.assets); + class PackageInput {} + const accessor = Object.defineProperty({}, 'documents', { get: () => [], enumerable: true }); + expect(() => createBasePackage('target', new PackageInput() as never, current.assets)).toThrow('plain object'); + expect(() => createBasePackage('target', accessor as never, current.assets)).toThrow('data property'); + expect(() => createBasePackage('target', { ...input, [Symbol('hidden')]: true } as never, current.assets)).toThrow('Symbol'); + expect(() => createBasePackage('target', { + ...input, + compatibility: [Object.defineProperty({}, 'subject', { get: () => 'skill:review', enumerable: true }) as never], + }, current.assets)).toThrow('data property'); + + const base = createBasePackage('target', input, current.assets); + const cycle: Record = {}; + cycle.self = cycle; + expect(() => mergePackageContributions('target', base, [{ + owner: 'extension:hooks', + contribution: { + documentFields: [{ document: 'manifest', path: ['extensions', 'hooks'], value: cycle as never }], + compatibility: [], + }, + }], current.assets)).toThrow('cycles'); + }); + + it('rejects behavior-bearing or sparse Document field paths without invoking getters', async () => { + const current = await fixture(); + const input = await baseInput(current.assets); + let getterCalls = 0; + const accessorPath: unknown[] = []; + Object.defineProperty(accessorPath, '0', { + enumerable: true, + get: () => { + getterCalls += 1; + return 'extensions'; + }, + }); + accessorPath.length = 1; + const sparsePath = new Array(1); + + expect(() => createBasePackage('target', { + ...input, + documents: [{ ...input.documents[0]!, extensionPoints: [accessorPath as never] }], + }, current.assets)).toThrow('dense'); + expect(getterCalls).toBe(0); + expect(() => createBasePackage('target', { + ...input, + documents: [{ ...input.documents[0]!, finalizationPoints: [sparsePath as never] }], + }, current.assets)).toThrow('dense'); + + const base = createBasePackage('target', input, current.assets); + expect(() => mergePackageContributions('target', base, [{ + owner: 'extension:hooks', + contribution: { + documentFields: [{ document: 'manifest', path: accessorPath as never, value: true }], + compatibility: [], + }, + }], current.assets)).toThrow('dense'); + expect(getterCalls).toBe(0); + + const finalizable = createBasePackage('target', { + ...input, + documents: [{ ...input.documents[0]!, finalizationPoints: [['extensions', 'agents']] }], + }, current.assets); + const merged = mergePackageContributions('target', finalizable, [], current.assets); + await expect(finalizePrimaryPackage('target', 'plugin', merged, { + id: 'plugin', + type: 'plugin', + documentFields: [{ document: 'manifest', path: sparsePath as never, value: true }], + }, current.assets)).rejects.toThrow('dense'); + }); + + it('rejects signed Source and Asset capabilities inside opaque Component JSON', async () => { + const current = await fixture(); + const base = createBasePackage('target', await baseInput(current.assets), current.assets); + const asset = await current.assets.service('extension:private').fromBytes({ + bytes: 'private', + origin: { operation: 'private-component' }, + }); + const sourceRoot = path.join(current.root, 'src', 'private'); + await fs.mkdir(sourceRoot, { recursive: true }); + await fs.writeFile(path.join(sourceRoot, 'source.ts'), 'export default true;\n'); + const directory = await current.sources.issueRoot('extension:private', sourceRoot); + const source = await current.sources.service('extension:private').file(directory, 'source.ts'); + const contribution = (value: unknown) => [{ + owner: 'extension:private', + subjects: [{ subject: 'private:resource', capabilities: ['component'] }], + contribution: { + components: [{ subject: 'private:resource', value: { nested: value } as never }], + compatibility: [{ subject: 'private:resource', capability: 'component', level: 'native' as const, reason: 'Native.' }], + }, + }]; + + expect(() => mergePackageContributions('target', base, contribution(asset), current.assets)) + .toThrow('must not contain Core capability references'); + expect(() => mergePackageContributions('target', base, contribution(source), current.assets)) + .toThrow('must not contain Core capability references'); + expect(() => mergePackageContributions('target', base, contribution({ kind: 'bytes-asset', id: asset.id }), current.assets)) + .not.toThrow(); + }); + + it('rejects signed capabilities inside every Package Document JSON entry', async () => { + const current = await fixture(); + const asset = await current.assets.service('platform:target').fromBytes({ + bytes: 'private', + origin: { operation: 'document-capability' }, + }); + const input = await baseInput(current.assets); + + expect(() => createBasePackage('target', { + ...input, + documents: [{ ...input.documents[0]!, value: { extensions: {}, invalid: asset } as never }], + }, current.assets)).toThrow('must not contain Core capability references'); + + const base = createBasePackage('target', input, current.assets); + expect(() => mergePackageContributions('target', base, [{ + owner: 'extension:hooks', + contribution: { + documentFields: [{ document: 'manifest', path: ['extensions', 'hooks'], value: asset as never }], + compatibility: [], + }, + }], current.assets)).toThrow('must not contain Core capability references'); + + const finalizable = createBasePackage('target', { + ...input, + documents: [{ ...input.documents[0]!, finalizationPoints: [['extensions', 'agents']] }], + }, current.assets); + const merged = mergePackageContributions('target', finalizable, [], current.assets); + await expect(finalizePrimaryPackage('target', 'plugin', merged, { + id: 'plugin', + type: 'plugin', + documentFields: [{ document: 'manifest', path: ['extensions', 'agents'], value: asset as never }], + }, current.assets)).rejects.toThrow('must not contain Core capability references'); + }); + + it('finalizes a primary Unit by automatically inheriting assets and Core-encoded Documents', async () => { + const current = await fixture(); + const base = createBasePackage('target', await baseInput(current.assets), current.assets); + const finalAsset = await current.assets.service('platform:target').fromBytes({ bytes: 'final', origin: { operation: 'final-manifest' } }); + const merged = mergePackageContributions('target', base, [], current.assets); + const primary = await finalizePrimaryPackage('target', 'plugin', merged, { + id: 'plugin', type: 'plugin', assets: [{ path: 'final.txt', asset: finalAsset }], + }, current.assets); + + expect(primary.assets.map(asset => asset.path)).toEqual(['README.md', 'final.txt', 'plugin.json']); + const documentAsset = primary.assets.find(asset => asset.path === 'plugin.json')!; + expect(new TextDecoder().decode(await current.assets.service('platform:target').read(documentAsset.asset))).toContain('"extensions"'); + await expect(finalizePrimaryPackage('target', 'plugin', merged, { + id: 'plugin', type: 'workspace', + }, current.assets)).rejects.toThrow('delivery type'); + /** finalize 不能借助既有 grant 把其他 owner ref 伪装成新增 Platform Asset。 */ + const foreign = await current.assets.service('extension:foreign').fromBytes({ bytes: 'foreign', origin: { operation: 'foreign' } }); + current.assets.grant('extension:foreign', 'platform:target', foreign); + await expect(finalizePrimaryPackage('target', 'plugin', merged, { + id: 'plugin', type: 'plugin', assets: [{ path: 'foreign.txt', asset: foreign }], + }, current.assets)).rejects.toThrow('current Platform'); + }); + + it('allows a Platform to add only its declared finalization fields after contribution merge', async () => { + const current = await fixture(); + const readme = await current.assets.service('platform:target').fromBytes({ bytes: 'readme\n', origin: { operation: 'platform-readme' } }); + const base = createBasePackage('target', { + documents: [ + { + id: 'manifest', + path: 'plugin.json', + format: 'json', + value: { extensions: {} }, + extensionPoints: [['extensions', 'hooks']], + finalizationPoints: [['extensions', 'agents']], + }, + { + id: 'secondary', + path: 'secondary.json', + format: 'json', + value: { extensions: {} }, + extensionPoints: [], + finalizationPoints: [], + }, + ], + assets: [{ path: 'README.md', asset: readme }], + compatibility: [], + metadata: [], + }, current.assets); + const merged = mergePackageContributions('target', base, [{ + owner: 'extension:hooks', + contribution: { + documentFields: [{ document: 'manifest', path: ['extensions', 'hooks'], value: { enabled: true } }], + compatibility: [], + }, + }], current.assets); + + const primary = await finalizePrimaryPackage('target', 'plugin', merged, { + id: 'plugin', + type: 'plugin', + documentFields: [{ document: 'manifest', path: ['extensions', 'agents'], value: './agents/' }], + }, current.assets); + const manifest = primary.assets.find(asset => asset.path === 'plugin.json')!; + expect(new TextDecoder().decode(await current.assets.service('platform:target').read(manifest.asset))).toContain('"agents": "./agents/"'); + + await expect(finalizePrimaryPackage('target', 'plugin', merged, { + id: 'plugin', type: 'plugin', documentFields: [{ document: 'manifest', path: ['extensions', 'hooks'], value: true }], + }, current.assets)).rejects.toThrow('undeclared finalization point'); + await expect(finalizePrimaryPackage('target', 'plugin', merged, { + id: 'plugin', type: 'plugin', documentFields: [{ document: 'secondary', path: ['extensions', 'agents'], value: true }], + }, current.assets)).rejects.toThrow('undeclared finalization point'); + await expect(finalizePrimaryPackage('target', 'plugin', merged, { + id: 'plugin', type: 'plugin', documentFields: [{ document: 'manifest', path: ['extensions', 'agents'], value: true }, { document: 'manifest', path: ['extensions', 'agents'], value: false }], + }, current.assets)).rejects.toThrow('duplicated'); + expect(() => createBasePackage('target', { + documents: [{ + id: 'manifest', path: 'plugin.json', format: 'json', value: { extensions: {} }, + extensionPoints: [['extensions', 'hooks']], finalizationPoints: [['extensions', 'hooks']], + }], + assets: [{ path: 'README.md', asset: readme }], compatibility: [], metadata: [], + }, current.assets)).toThrow('overlaps'); + }); + + it('preserves trusted Component provenance on finalization Assets and contribution-driven Documents', async () => { + const current = await fixture(); + const base = createBasePackage('target', { + documents: [{ + id: 'manifest', path: 'plugin.json', format: 'json', value: { extensions: {} }, + extensionPoints: [], finalizationPoints: [['extensions', 'components']], + }], assets: [], compatibility: [], metadata: [], + }, current.assets); + const merged = mergePackageContributions('target', base, [{ + owner: 'extension:private', + subjects: [{ subject: 'private:resource', capabilities: ['component'] }], + contribution: { + components: [{ subject: 'private:resource', value: { opaque: true } }], + compatibility: [{ subject: 'private:resource', capability: 'component', level: 'native', reason: 'Native.' }], + }, + }], current.assets); + const origin = merged.components[0]!.origin; + const scope = current.assets.componentFinalizationScope('target', 'platform:target', merged.components); + const rendered = await scope.service.fromBytes({ + bytes: 'rendered\n', origin: { operation: 'platform-component', componentOrigins: [origin] }, + }); + scope.close(); + const primary = await finalizePrimaryPackage('target', 'plugin', merged, { + id: 'plugin', type: 'plugin', assets: [{ path: 'components/one.md', asset: rendered }], + documentFields: [{ document: 'manifest', path: ['extensions', 'components'], value: './components/', componentOrigins: [origin] }], + }, current.assets); + expect(current.assets.describe('platform:target', rendered).origin).toMatchObject({ + contributors: [{ owner: 'extension:private', subject: 'private:resource' }], + }); + const manifest = primary.assets.find(asset => asset.path === 'plugin.json')!; + expect(current.assets.describe('platform:target', manifest.asset).origin).toMatchObject({ + operation: 'package-document', contributors: [{ owner: 'extension:private', subject: 'private:resource' }], + }); + await expect(finalizePrimaryPackage('target', 'plugin', merged, { + id: 'plugin', type: 'plugin', + documentFields: [{ document: 'manifest', path: ['extensions', 'components'], value: true, componentOrigins: [Object.freeze({ ...origin }) as typeof origin] }], + }, current.assets)).rejects.toThrow('not authorized'); + }); +}); diff --git a/packages/core/test/package/report-builder.test.ts b/packages/core/test/package/report-builder.test.ts new file mode 100644 index 0000000..4496c74 --- /dev/null +++ b/packages/core/test/package/report-builder.test.ts @@ -0,0 +1,110 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { PackageUnitSnapshot } from '../../src/contracts/index.js'; +import { AssetRegistry } from '../../src/services/assets.js'; +import { BuildSessionScope } from '../../src/services/session-scope.js'; +import { SourceRegistry } from '../../src/services/sources.js'; +import { WorkDirectoryRegistry } from '../../src/services/work-directories.js'; +import { createBuildReport, serializeBuildReport } from '../../src/package/report-builder.js'; + +/** BuildReport 测试临时根。 */ +const roots: string[] = []; + +/** @returns 带一个 structured-origin Asset 的 Package fixture。 */ +async function fixture() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-report-v3-')); + roots.push(root); + const scope = new BuildSessionScope(); + const sources = new SourceRegistry(scope, root); + const work = new WorkDirectoryRegistry(scope, path.join(root, '.work')); + const assets = new AssetRegistry(scope, sources, work); + const asset = await assets.service('platform:target').fromBytes({ + bytes: 'content\n', + mode: 0o755, + origin: { operation: 'generated-command', subjects: ['command:check'] }, + }); + const unit: PackageUnitSnapshot = Object.freeze({ + platform: 'target', id: 'plugin', type: 'plugin', role: 'primary', + assets: Object.freeze([{ path: 'bin/main.mjs', owner: 'platform:target', asset }]), + compatibility: Object.freeze([]), metadata: Object.freeze([]), + }); + return { root, assets, unit }; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('BuildReport schema v3', () => { + it('sorts deterministically and includes structured Asset provenance without bytes', async () => { + const current = await fixture(); + const input = { + frameworkVersion: '1.0.0-beta.1', compilerVersion: '1.2.2', success: true, + command: 'inspect' as const, mode: 'production' as const, committed: false, + components: [ + { kind: 'skill' as const, id: 'z', location: { path: 'src/skills/z/SKILL.md' } }, + { kind: 'command' as const, id: 'a', location: { path: 'src/commands/a.md' } }, + ], + runtimes: [], extensions: [], + platforms: [{ id: 'target', selected: true, success: true, packageIds: ['plugin'] }], + packages: [current.unit], validatedPackages: ['target/plugin'], + compatibility: [], metadata: [], diagnostics: [], assets: current.assets, + }; + const first = serializeBuildReport(createBuildReport(input)); + const second = serializeBuildReport(createBuildReport({ ...input, components: [...input.components].reverse() })); + + expect(first).toBe(second); + expect(first.endsWith('\n')).toBe(true); + const parsed = JSON.parse(first); + expect(parsed).toMatchObject({ + schemaVersion: 3, + packages: [{ validated: true, assets: [{ + path: 'bin/main.mjs', owner: 'platform:target', mode: 0o755, + origin: { type: 'generated', owner: 'platform:target', operation: 'generated-command', subjects: ['command:check'] }, + }] }], + }); + expect(parsed.packages[0].assets[0].origin).toEqual({ + type: 'generated', + owner: 'platform:target', + operation: 'generated-command', + subjects: ['command:check'], + }); + expect(first).not.toContain('content'); + expect(first).not.toContain(current.root); + expect(first).not.toContain('timestamp'); + }); + + it('rejects forged report data instead of serializing behavior or bytes', async () => { + const current = await fixture(); + const report = createBuildReport({ + frameworkVersion: '1.0.0-beta.1', compilerVersion: '1.2.2', success: true, + command: 'inspect', mode: 'production', committed: false, components: [], runtimes: [], extensions: [], platforms: [], + packages: [current.unit], compatibility: [], metadata: [], diagnostics: [], assets: current.assets, + }); + expect(() => serializeBuildReport({ ...report, unsafe: () => 'secret' } as never)).toThrow('JSON values'); + }); + + it('deep-copies and freezes nested report input before callers can mutate it', async () => { + const current = await fixture(); + const component = { kind: 'command' as const, id: 'check', location: { path: 'src/commands/check.md' } }; + const diagnostic = { + phase: 'package' as const, code: 'PACKAGE_NOTE', severity: 'warning' as const, + message: 'Stable.', related: [{ path: 'src/commands/check.md', line: 1 }], + }; + const report = createBuildReport({ + frameworkVersion: '1.0.0-beta.1', compilerVersion: '1.2.2', success: true, + command: 'inspect', mode: 'production', committed: false, + components: [component], runtimes: [], extensions: [], platforms: [], packages: [current.unit], + compatibility: [], metadata: [], diagnostics: [diagnostic], assets: current.assets, + }); + component.location.path = 'mutated'; + diagnostic.related[0]!.path = 'mutated'; + + expect(report.components[0]?.location.path).toBe('src/commands/check.md'); + expect(report.diagnostics[0]?.related?.[0]?.path).toBe('src/commands/check.md'); + expect(Object.isFrozen(report.components[0]?.location)).toBe(true); + expect(Object.isFrozen(report.diagnostics[0]?.related)).toBe(true); + }); +}); diff --git a/packages/core/test/resources/canonical-provider.test.ts b/packages/core/test/resources/canonical-provider.test.ts new file mode 100644 index 0000000..608abae --- /dev/null +++ b/packages/core/test/resources/canonical-provider.test.ts @@ -0,0 +1,168 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { AssetRegistry } from '../../src/services/assets.js'; +import { BuildSessionScope } from '../../src/services/session-scope.js'; +import { DiagnosticRegistry } from '../../src/services/diagnostics.js'; +import { SourceRegistry } from '../../src/services/sources.js'; +import { WatchRegistry } from '../../src/services/watch.js'; +import { WorkDirectoryRegistry } from '../../src/services/work-directories.js'; +import { discoverCanonicalProject } from '../../src/resources/canonical/provider.js'; +import { ResourceRegistry } from '../../src/resources/registry.js'; +import { resolveKernelConfig } from '../../src/config/resolver.js'; +import { definePlatform } from '../../src/api/definitions.js'; + +/** Canonical Provider 测试临时根。 */ +const roots: string[] = []; + +/** @returns 最小 Platform definition。 */ +function platform(id: string) { + return definePlatform({ + id, apiVersion: '1', deliveryType: 'plugin', + createSession: () => ({ + createPackage: () => ({ documents: [], assets: [], compatibility: [], metadata: [] }), + finalizePackage: () => ({ id: 'plugin', type: 'plugin' }), + validatePackage: () => undefined, + }), + }); +} + +/** + * 创建 Canonical Provider BuildSession fixture。 + * + * @returns 工程根、registries 与发现函数。 + */ +async function fixture() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-canonical-provider-')); + roots.push(root); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), 'export default {};\n'); + const configured = resolveKernelConfig({ + name: 'canonical-fixture', version: '1.0.0', description: 'Canonical fixture.', platforms: [platform('codex')], + }, { projectRoot: root, configFile: path.join(root, 'acplugin.config.ts'), command: 'build', mode: 'production' }).config!; + const scope = new BuildSessionScope(); + const sources = new SourceRegistry(scope, root); + const work = new WorkDirectoryRegistry(scope, path.join(root, '.work')); + const assets = new AssetRegistry(scope, sources, work); + const watch = new WatchRegistry(scope, root); + const diagnostics = new DiagnosticRegistry(); + const discover = async () => { + const claims = await new ResourceRegistry({ config: configured, sources, watch, diagnostics }).claim(); + return discoverCanonicalProject({ + metadata: configured.metadata, + platformIds: configured.platforms.map(item => item.definition.id), + claims, + sources, + assets, + diagnostics, + }); + }; + return { root, assets, diagnostics, discover }; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('Canonical Provider', () => { + it('builds an immutable Component graph with safe locations and auxiliary AssetRefs', async () => { + const current = await fixture(); + await fs.mkdir(path.join(current.root, 'src', 'commands'), { recursive: true }); + await fs.mkdir(path.join(current.root, 'src', 'skills', 'review', 'references'), { recursive: true }); + await fs.mkdir(path.join(current.root, 'src', 'agents'), { recursive: true }); + await fs.writeFile(path.join(current.root, 'src', 'commands', 'check.md'), `--- +description: Check a release. +argumentHint: +requires: + skills: [review] +platforms: + codex: + model: fast +--- +Check {{arguments}}. +`); + await fs.writeFile(path.join(current.root, 'src', 'skills', 'review', 'SKILL.md'), `--- +description: Review a release. +requires: + agents: [reviewer] +--- +Review evidence. +`); + await fs.writeFile(path.join(current.root, 'src', 'skills', 'review', 'references', 'data.bin'), Uint8Array.of(0xff, 0x00)); + await fs.writeFile(path.join(current.root, 'src', 'agents', 'reviewer.md'), `--- +description: Review correctness. +model: capable +capabilities: [filesystem:read, search] +--- +Return findings. +`); + + const project = await current.discover(); + expect(current.diagnostics.diagnostics).toEqual([]); + expect(project.commands[0]).toMatchObject({ + id: 'check', + location: { path: 'src/commands/check.md', bodyLine: 10 }, + requires: { skills: ['review'], agents: [] }, + platforms: { codex: { model: 'fast' } }, + }); + expect(project.skills[0]?.auxiliaryFiles[0]?.path).toBe('references/data.bin'); + expect(current.assets.describe('framework:canonical', project.skills[0]!.auxiliaryFiles[0]!.asset).origin).toEqual({ + type: 'source', resource: 'framework:canonical', path: 'src/skills/review/references/data.bin', + }); + expect(project.agents[0]).toMatchObject({ id: 'reviewer', model: 'capable', capabilities: ['filesystem:read', 'search'] }); + expect(JSON.stringify(project)).not.toContain(current.root); + expect(Object.isFrozen(project)).toBe(true); + expect(Object.isFrozen(project.skills[0]?.auxiliaryFiles)).toBe(true); + }); + + it('reports malformed authoring, platform JSON and invocation rules without creating unsafe data', async () => { + const current = await fixture(); + await fs.mkdir(path.join(current.root, 'src', 'commands', 'nested'), { recursive: true }); + await fs.mkdir(path.join(current.root, 'src', 'skills', 'disabled'), { recursive: true }); + await fs.mkdir(path.join(current.root, 'src', 'agents'), { recursive: true }); + await fs.writeFile(path.join(current.root, 'src', 'commands', 'broken.md'), '---\ndescription: [\n---\nBroken.\n'); + await fs.writeFile(path.join(current.root, 'src', 'commands', 'placeholder.md'), '---\ndescription: Placeholder.\nplatforms:\n ghost: {}\n---\nUse {{ args }}.\n'); + await fs.writeFile(path.join(current.root, 'src', 'skills', 'disabled', 'SKILL.md'), '---\ndescription: Disabled.\ninvocation:\n user: false\n model: false\n---\nDisabled.\n'); + await fs.writeFile(path.join(current.root, 'src', 'agents', 'unsafe.md'), '---\ndescription: Unsafe.\ncapabilities: [raw-tool]\n---\nUnsafe.\n'); + + await current.discover(); + expect(current.diagnostics.diagnostics.map(item => item.code)).toEqual(expect.arrayContaining([ + 'COMMAND_ENTRY_INVALID', 'FRONTMATTER_INVALID', 'COMMAND_PLACEHOLDER_INVALID', + 'COMPONENT_PLATFORM_NOT_CONFIGURED', 'SKILL_INVOCATION_EMPTY', 'AGENT_CAPABILITY_INVALID', + ])); + }); + + it('rejects invalid UTF-8 Markdown and blank dependency values', async () => { + const current = await fixture(); + await fs.mkdir(path.join(current.root, 'src', 'commands'), { recursive: true }); + await fs.writeFile(path.join(current.root, 'src', 'commands', 'binary.md'), Uint8Array.of(0xff, 0xfe)); + await fs.writeFile(path.join(current.root, 'src', 'commands', 'blank.md'), `--- +description: Blank dependency. +requires: + skills: [' '] +--- +Check dependencies. +`); + + await current.discover(); + expect(current.diagnostics.diagnostics.map(item => item.code)).toEqual(expect.arrayContaining([ + 'MARKDOWN_UTF8_INVALID', 'FRONTMATTER_STRING_ARRAY', + ])); + }); + + it('rejects missing, self, duplicate and cyclic Component dependencies', async () => { + const current = await fixture(); + await fs.mkdir(path.join(current.root, 'src', 'commands'), { recursive: true }); + await fs.mkdir(path.join(current.root, 'src', 'skills', 'alpha'), { recursive: true }); + await fs.mkdir(path.join(current.root, 'src', 'skills', 'beta'), { recursive: true }); + await fs.writeFile(path.join(current.root, 'src', 'commands', 'run.md'), '---\ndescription: Run.\nrequires:\n skills: [missing, missing]\n---\nRun.\n'); + await fs.writeFile(path.join(current.root, 'src', 'skills', 'alpha', 'SKILL.md'), '---\ndescription: Alpha.\nrequires:\n skills: [alpha, beta]\n---\nAlpha.\n'); + await fs.writeFile(path.join(current.root, 'src', 'skills', 'beta', 'SKILL.md'), '---\ndescription: Beta.\nrequires:\n skills: [alpha]\n---\nBeta.\n'); + + await current.discover(); + expect(current.diagnostics.diagnostics.map(item => item.code)).toEqual(expect.arrayContaining([ + 'FRONTMATTER_ARRAY_DUPLICATE', 'COMPONENT_DEPENDENCY_MISSING', + 'COMPONENT_DEPENDENCY_SELF', 'COMPONENT_DEPENDENCY_CYCLE', + ])); + }); +}); diff --git a/packages/core/test/resources/extension-provider.test.ts b/packages/core/test/resources/extension-provider.test.ts new file mode 100644 index 0000000..27bfdcf --- /dev/null +++ b/packages/core/test/resources/extension-provider.test.ts @@ -0,0 +1,299 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { + CanonicalProject, + CompilerService, + ExecutionService, + ExtensionSession, + ModuleService, + PlatformBasePackageSnapshot, + PlatformIntegrationDescription, +} from '../../src/contracts/index.js'; +import { defineExtension } from '../../src/api/definitions.js'; +import { AssetRegistry } from '../../src/services/assets.js'; +import { BuildSessionScope } from '../../src/services/session-scope.js'; +import { DiagnosticRegistry } from '../../src/services/diagnostics.js'; +import { SourceRegistry } from '../../src/services/sources.js'; +import { WorkDirectoryRegistry } from '../../src/services/work-directories.js'; +import { + buildExtension, + collectExtensionContributions, + discoverExtension, + preflightExtensionConsumers, + validateExtension, +} from '../../src/resources/extensions.js'; + +/** Extension Provider 测试根。 */ +const roots: string[] = []; + +/** 空 canonical Project。 */ +const project: CanonicalProject = Object.freeze({ + metadata: Object.freeze({ name: 'fixture', version: '1.0.0', description: 'Fixture.', keywords: Object.freeze([]) }), + commands: Object.freeze([]), skills: Object.freeze([]), agents: Object.freeze([]), publicFiles: Object.freeze([]), +}); + +/** 测试不调用 Module Host 的类型完备 service。 */ +const modules: ModuleService = Object.freeze({ + loadDefault: async () => undefined as T, +}); + +/** 测试 Extension 不调用 Compiler Host 的类型完备 service。 */ +const compiler: CompilerService = Object.freeze({ + engine: Object.freeze({ name: 'rolldown', version: 'test' }), + compile: async () => { throw new Error('Compiler should not be called by this fixture.'); }, +}); + +/** 测试 Extension 不调用 Execution Host 的类型完备 service。 */ +const execution: ExecutionService = Object.freeze({ + runNode: async () => { throw new Error('Execution should not be called by this fixture.'); }, +}); + +/** 选中目标 Platform 的稳定公开 description。 */ +const targetPlatform: PlatformIntegrationDescription = Object.freeze({ + kind: 'platform', id: 'target', apiVersion: '1', options: Object.freeze({}), capabilities: Object.freeze({}), +}); + +/** Contributor 共享读取的最小 base Package。 */ +const base: PlatformBasePackageSnapshot = Object.freeze({ + documents: Object.freeze([]), assets: Object.freeze([]), compatibility: Object.freeze([]), metadata: Object.freeze([]), +}); + +/** + * 创建 owner-scoped Extension Provider fixture。 + * + * @returns refs、registries 和通用调用参数。 + */ +async function fixture() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-extension-provider-')); + roots.push(root); + const resource = path.join(root, 'src', 'owned'); + await fs.mkdir(resource, { recursive: true }); + await fs.writeFile(path.join(resource, 'descriptor.ts'), 'export default {};\n'); + const scope = new BuildSessionScope(); + const sources = new SourceRegistry(scope, root); + const work = new WorkDirectoryRegistry(scope, path.join(root, '.work')); + const assets = new AssetRegistry(scope, sources, work); + const diagnostics = new DiagnosticRegistry(); + const owner = 'extension:owned'; + const rootRef = await sources.issueRoot(owner, resource); + const file = await sources.service(owner).file(rootRef, 'descriptor.ts'); + const asset = await assets.service(owner).fromSource(file); + const extension = defineExtension({ + id: 'owned', apiVersion: '1', resourceRoots: ['owned'], + createSession: () => { throw new Error('test supplies session directly'); }, + }); + return { root, owner, sources, assets, diagnostics, rootRef, file, asset, extension }; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('Extension Provider state boundaries', () => { + it('copies discovered/validated state while preserving authorized ref identity and sorting subjects', async () => { + const current = await fixture(); + const mutable = { list: ['original'] }; + const session: ExtensionSession = { + discover: () => ({ mutable, file: current.file, asset: current.asset }), + validate: (_context, discovered) => ({ + state: { discovered, enabled: true }, + subjects: [ + { subject: 'hook:zeta', capabilities: ['wire', 'runtime'] }, + { subject: 'hook:alpha', capabilities: ['runtime'] }, + ], + }), + build: () => ({ state: {} }), + contributors: [], + }; + const discovered = await discoverExtension({ + extension: current.extension, + session, + roots: { owned: current.rootRef }, + command: 'build', mode: 'production', + sources: current.sources, + assets: current.assets, + modules, + diagnostics: current.diagnostics, + }); + mutable.list.push('changed'); + expect(discovered?.state).toMatchObject({ mutable: { list: ['original'] }, file: current.file, asset: current.asset }); + expect(Object.isFrozen((discovered?.state as { mutable: object }).mutable)).toBe(true); + const validated = await validateExtension({ + discovered: discovered!, session, project, command: 'build', mode: 'production', + sources: current.sources, assets: current.assets, diagnostics: current.diagnostics, + }); + expect(validated.subjects).toEqual([ + { subject: 'hook:alpha', capabilities: ['runtime'] }, + { subject: 'hook:zeta', capabilities: ['runtime', 'wire'] }, + ]); + expect(Object.isFrozen(validated.state)).toBe(true); + }); + + it('rejects functions, accessors, cycles, classes and forged refs in discovered state', async () => { + const check = async (state: unknown, expected: string): Promise => { + const current = await fixture(); + const session: ExtensionSession = { + discover: () => state, + validate: () => ({ state: {}, subjects: [] }), + build: () => ({ state: {} }), + contributors: [], + }; + await expect(discoverExtension({ + extension: current.extension, + session, + roots: { owned: current.rootRef }, + command: 'build', mode: 'production', sources: current.sources, assets: current.assets, + modules, diagnostics: current.diagnostics, + })).rejects.toThrow(expected); + }; + const cycle: Record = {}; + cycle.self = cycle; + class State {} + const accessor = Object.defineProperty({}, 'value', { get: () => 'hidden', enumerable: true }); + const current = await fixture(); + + await check({ run: () => undefined }, 'unsupported'); + await check(accessor, 'data property'); + await check(cycle, 'cycle'); + await check(new State(), 'plain objects'); + await check({ file: Object.freeze({ ...current.file }) }, 'unauthorized SourceRef'); + }); + + it('rejects malformed and duplicate validation subjects', async () => { + const current = await fixture(); + const session: ExtensionSession = { + discover: () => ({}), + validate: () => ({ + state: {}, + subjects: [ + { subject: 'duplicate', capabilities: ['runtime'] }, + { subject: 'duplicate', capabilities: ['runtime'] }, + ], + }), + build: () => ({ state: {} }), + contributors: [], + }; + const discovered = await discoverExtension({ + extension: current.extension, session, roots: { owned: current.rootRef }, command: 'build', mode: 'production', + sources: current.sources, assets: current.assets, + modules, diagnostics: current.diagnostics, + }); + await expect(validateExtension({ + discovered: discovered!, session, project, command: 'build', mode: 'production', + sources: current.sources, assets: current.assets, diagnostics: current.diagnostics, + })).rejects.toThrow('duplicated'); + }); + + it('preflights consumers, skips unused builds and creates explicit unsupported compatibility', async () => { + const current = await fixture(); + let builds = 0; + const session: ExtensionSession = { + discover: () => ({}), + validate: () => ({ state: {}, subjects: [{ subject: 'hook:check', capabilities: ['runtime', 'wire'] }] }), + build: () => { + builds += 1; + return { state: {} }; + }, + contributors: [], + }; + const discovered = await discoverExtension({ + extension: current.extension, session, roots: { owned: current.rootRef }, command: 'build', mode: 'production', + sources: current.sources, assets: current.assets, modules, diagnostics: current.diagnostics, + }); + const validated = await validateExtension({ + discovered: discovered!, session, project, command: 'build', mode: 'production', + sources: current.sources, assets: current.assets, diagnostics: current.diagnostics, + }); + const plan = preflightExtensionConsumers({ validated, session, platforms: [targetPlatform] }); + const built = await buildExtension({ + plan, session, project, command: 'build', mode: 'production', compiler, execution, + sources: current.sources, assets: current.assets, diagnostics: current.diagnostics, + }); + const contributions = await collectExtensionContributions({ + platform: targetPlatform, base, project, command: 'build', mode: 'production', + plans: [plan], built: [], assets: current.assets, diagnostics: current.diagnostics, + }); + + expect(plan.requiresBuild).toBe(false); + expect(builds).toBe(0); + expect(built).toBeUndefined(); + expect(contributions[0]?.contribution.compatibility).toEqual([ + expect.objectContaining({ subject: 'hook:check', capability: 'runtime', level: 'unsupported' }), + expect.objectContaining({ subject: 'hook:check', capability: 'wire', level: 'unsupported' }), + ]); + }); + + it('builds once for matching consumers and passes one immutable base to the Contributor', async () => { + const current = await fixture(); + let receivedBase: PlatformBasePackageSnapshot | undefined; + let builds = 0; + const session: ExtensionSession = { + discover: () => ({}), + validate: () => ({ state: { asset: current.asset }, subjects: [{ subject: 'hook:check', capabilities: ['runtime'] }] }), + build: (_context, validated) => { + builds += 1; + return { state: { asset: validated.asset } }; + }, + contributors: [{ + platform: 'target', platformApiVersion: '1', + contribute: (context, built) => { + receivedBase = context.base; + return { + assets: [{ path: 'runtime/hook.mjs', asset: built.asset }], + compatibility: [{ subject: 'hook:check', capability: 'runtime', level: 'native', reason: 'Native.' }], + }; + }, + }], + }; + const discovered = await discoverExtension({ + extension: current.extension, session, roots: { owned: current.rootRef }, command: 'build', mode: 'production', + sources: current.sources, assets: current.assets, modules, diagnostics: current.diagnostics, + }); + const validated = await validateExtension({ + discovered: discovered!, session, project, command: 'build', mode: 'production', + sources: current.sources, assets: current.assets, diagnostics: current.diagnostics, + }); + const plan = preflightExtensionConsumers({ validated, session, platforms: [targetPlatform] }); + const built = await buildExtension({ + plan, session, project, command: 'build', mode: 'production', compiler, execution, + sources: current.sources, assets: current.assets, diagnostics: current.diagnostics, + }); + const contributions = await collectExtensionContributions({ + platform: targetPlatform, base, project, command: 'build', mode: 'production', + plans: [plan], built: [built!], assets: current.assets, diagnostics: current.diagnostics, + }); + + expect(plan.requiresBuild).toBe(true); + expect(builds).toBe(1); + expect(receivedBase).toBe(base); + expect(Object.isFrozen(built?.state)).toBe(true); + expect(contributions[0]).toMatchObject({ owner: 'extension:owned', subjects: [{ subject: 'hook:check' }] }); + }); + + it('rejects duplicate, wrong-version and accessor Contributor definitions during preflight', async () => { + const current = await fixture(); + const validated = Object.freeze({ extension: current.extension, state: Object.freeze({}), subjects: Object.freeze([]) }); + const contributor = Object.freeze({ + platform: 'target', platformApiVersion: '1' as const, + contribute: () => ({ compatibility: [] }), + }); + const session = (contributors: readonly unknown[]): ExtensionSession => ({ + discover: () => ({}), validate: () => ({ state: {}, subjects: [] }), build: () => ({ state: {} }), + contributors: contributors as never, + }); + + expect(() => preflightExtensionConsumers({ validated, session: session([contributor, contributor]), platforms: [targetPlatform] })).toThrow('duplicate'); + expect(() => preflightExtensionConsumers({ + validated, session: session([{ ...contributor, platformApiVersion: '2' }]), platforms: [targetPlatform], + })).toThrow('API version 1'); + expect(() => preflightExtensionConsumers({ + validated, + session: session([Object.defineProperty({ platformApiVersion: '1', contribute: () => ({ compatibility: [] }) }, 'platform', { + get: () => 'target', enumerable: true, + })]), + platforms: [targetPlatform], + })).toThrow('data property'); + }); +}); diff --git a/packages/core/test/resources/project-graph.test.ts b/packages/core/test/resources/project-graph.test.ts new file mode 100644 index 0000000..8075aca --- /dev/null +++ b/packages/core/test/resources/project-graph.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import type { CanonicalProject, NodeRuntimeResource, PublicResourceFile } from '../../src/contracts/index.js'; +import { assembleProjectGraph } from '../../src/resources/project-graph.js'; + +describe('Project Graph assembly', () => { + it('preserves immutable provider identities and exposes no project root', () => { + const canonical: CanonicalProject = Object.freeze({ + metadata: Object.freeze({ name: 'graph', version: '1.0.0', description: 'Graph.', keywords: Object.freeze([]) }), + commands: Object.freeze([]), + skills: Object.freeze([]), + agents: Object.freeze([]), + publicFiles: Object.freeze([]), + }); + const publicFiles = Object.freeze([ + { path: 'schema.json', asset: Object.freeze({ kind: 'source-asset' }) }, + ]) as unknown as readonly PublicResourceFile[]; + const runtime = Object.freeze({ + target: 'node20', + entries: Object.freeze([ + { id: 'cli', kind: 'executable', source: Object.freeze({ kind: 'source-file', path: 'src/runtime/cli.ts' }) }, + ]), + }) as unknown as NodeRuntimeResource; + + const project = assembleProjectGraph(canonical, publicFiles, runtime); + expect(project.publicFiles).toBe(publicFiles); + expect(project.runtime).toBe(runtime); + expect(project.commands).toBe(canonical.commands); + expect(Object.isFrozen(project)).toBe(true); + expect(JSON.stringify(project)).not.toContain('/Users/'); + expect('root' in project).toBe(false); + }); +}); diff --git a/packages/core/test/resources/public-provider.test.ts b/packages/core/test/resources/public-provider.test.ts new file mode 100644 index 0000000..e9d52ac --- /dev/null +++ b/packages/core/test/resources/public-provider.test.ts @@ -0,0 +1,156 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { definePlatform } from '../../src/api/definitions.js'; +import { resolveKernelConfig } from '../../src/config/resolver.js'; +import { AssetRegistry } from '../../src/services/assets.js'; +import { BuildSessionScope } from '../../src/services/session-scope.js'; +import { DiagnosticRegistry } from '../../src/services/diagnostics.js'; +import { SourceRegistry } from '../../src/services/sources.js'; +import { WatchRegistry } from '../../src/services/watch.js'; +import { WorkDirectoryRegistry } from '../../src/services/work-directories.js'; +import { discoverPublicResources } from '../../src/resources/public.js'; + +/** Public Provider 测试根。 */ +const roots: string[] = []; + +/** @returns 最小 Platform。 */ +function platform() { + return definePlatform({ + id: 'target', apiVersion: '1', deliveryType: 'plugin', + createSession: () => ({ + createPackage: () => ({ documents: [], assets: [], compatibility: [], metadata: [] }), + finalizePackage: () => ({ id: 'plugin', type: 'plugin' }), + validatePackage: () => undefined, + }), + }); +} + +/** + * 创建 Public Provider fixture。 + * + * @param publicValue 作者 public 配置。 + * @returns 当前 BuildSession 和发现函数。 + */ +async function fixture(publicValue: unknown = undefined) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-public-provider-')); + roots.push(root); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), 'export default {};\n'); + const resolved = resolveKernelConfig({ + name: 'public-fixture', version: '1.0.0', description: 'Public fixture.', platforms: [platform()], + ...(publicValue === undefined ? {} : { public: publicValue }), + }, { projectRoot: root, configFile: path.join(root, 'acplugin.config.ts'), command: 'build', mode: 'production' }); + const scope = new BuildSessionScope(); + const sources = new SourceRegistry(scope, root); + const work = new WorkDirectoryRegistry(scope, path.join(root, '.work')); + const assets = new AssetRegistry(scope, sources, work); + const watch = new WatchRegistry(scope, root); + const diagnostics = new DiagnosticRegistry(); + const discover = () => discoverPublicResources({ config: resolved.config!, sources, assets, watch, diagnostics }); + return { root, configDiagnostics: resolved.diagnostics, assets, watch, diagnostics, discover }; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('Public Provider', () => { + it('copies the default full tree as SourceAssets with stable package-relative paths', async () => { + const current = await fixture(); + await fs.mkdir(path.join(current.root, 'public', 'bin'), { recursive: true }); + await fs.writeFile(path.join(current.root, 'public', 'bin', 'tool'), Uint8Array.of(0x00, 0xff)); + await fs.chmod(path.join(current.root, 'public', 'bin', 'tool'), 0o755); + + const resources = await current.discover(); + expect(current.configDiagnostics).toEqual([]); + expect(current.diagnostics.diagnostics).toEqual([]); + expect(resources.map(file => file.path)).toEqual(['bin/tool']); + expect(current.assets.describe('framework:public', resources[0]!.asset)).toMatchObject({ + mode: 0o755, + origin: { type: 'source', resource: 'framework:public', path: 'public/bin/tool' }, + }); + expect(current.watch.snapshot().identities).toContain('public'); + }); + + it('supports multiple project-root exact sources without scanning protected siblings', async () => { + const current = await fixture({ + dir: '.', + copy: [ + { from: 'schemas', to: 'schemas' }, + { from: 'rulepacks', to: 'runtime/rulepacks' }, + ], + }); + await fs.mkdir(path.join(current.root, 'schemas'), { recursive: true }); + await fs.mkdir(path.join(current.root, 'rulepacks'), { recursive: true }); + await fs.mkdir(path.join(current.root, 'node_modules'), { recursive: true }); + await fs.writeFile(path.join(current.root, 'schemas', 'schema.json'), '{}\n'); + await fs.writeFile(path.join(current.root, 'rulepacks', 'default.json'), '{}\n'); + /** project root 中其他目录 symlink 不应污染精确 copy 来源。 */ + await fs.symlink(path.join(current.root, 'schemas'), path.join(current.root, 'node_modules', 'linked'), 'dir'); + + const resources = await current.discover(); + expect(current.configDiagnostics).toEqual([]); + expect(current.diagnostics.diagnostics).toEqual([]); + expect(resources.map(file => file.path)).toEqual(['runtime/rulepacks/default.json', 'schemas/schema.json']); + }); + + it('rejects target collisions, missing sources and source symlinks deterministically', async () => { + const current = await fixture({ + copy: [ + { from: 'a/file.txt', to: 'Shared/file.txt' }, + { from: 'b/file.txt', to: 'shared/file.txt' }, + { from: 'missing.txt', to: 'missing.txt' }, + { from: 'linked.txt', to: 'linked.txt' }, + ], + }); + await fs.mkdir(path.join(current.root, 'public', 'a'), { recursive: true }); + await fs.mkdir(path.join(current.root, 'public', 'b'), { recursive: true }); + await fs.writeFile(path.join(current.root, 'public', 'a', 'file.txt'), 'a'); + await fs.writeFile(path.join(current.root, 'public', 'b', 'file.txt'), 'b'); + await fs.symlink(path.join(current.root, 'public', 'a', 'file.txt'), path.join(current.root, 'public', 'linked.txt')); + + const resources = await current.discover(); + expect(resources.map(file => file.path)).toEqual(['Shared/file.txt']); + expect(current.diagnostics.diagnostics.map(item => item.code)).toEqual(expect.arrayContaining([ + 'PUBLIC_TARGET_COLLISION', 'PUBLIC_SOURCE_MISSING', 'PUBLIC_SOURCE_INVALID', + ])); + }); + + it('defensively rejects unsafe targets after config resolution', async () => { + const current = await fixture({ copy: [{ from: 'file.txt', to: 'safe/file.txt' }] }); + await fs.mkdir(path.join(current.root, 'public'), { recursive: true }); + await fs.writeFile(path.join(current.root, 'public', 'file.txt'), 'public'); + const resolved = resolveKernelConfig({ + name: 'public-fixture', version: '1.0.0', description: 'Public fixture.', platforms: [platform()], + public: { copy: [{ from: 'file.txt', to: 'safe/file.txt' }] }, + }, { + projectRoot: current.root, + configFile: path.join(current.root, 'acplugin.config.ts'), + command: 'build', + mode: 'production', + }).config!; + const unsafe = { + ...resolved, + public: { ...resolved.public, copy: [{ ...resolved.public.copy![0]!, to: '../escape.txt' }] }, + }; + const scope = new BuildSessionScope(); + const sources = new SourceRegistry(scope, current.root); + const work = new WorkDirectoryRegistry(scope, path.join(current.root, '.unsafe-work')); + const assets = new AssetRegistry(scope, sources, work); + const watch = new WatchRegistry(scope, current.root); + const diagnostics = new DiagnosticRegistry(); + + expect(await discoverPublicResources({ config: unsafe, sources, assets, watch, diagnostics })).toEqual([]); + expect(diagnostics.diagnostics).toContainEqual(expect.objectContaining({ code: 'PUBLIC_TARGET_INVALID' })); + }); + + it('is silent for disabled or absent default Public roots', async () => { + const absent = await fixture(); + expect(await absent.discover()).toEqual([]); + expect(absent.diagnostics.diagnostics).toEqual([]); + const disabled = await fixture(false); + expect(await disabled.discover()).toEqual([]); + expect(disabled.diagnostics.diagnostics).toEqual([]); + }); +}); diff --git a/packages/core/test/resources/resource-registry.test.ts b/packages/core/test/resources/resource-registry.test.ts new file mode 100644 index 0000000..f5273c3 --- /dev/null +++ b/packages/core/test/resources/resource-registry.test.ts @@ -0,0 +1,127 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { defineExtension, definePlatform } from '../../src/api/definitions.js'; +import { resolveKernelConfig, type ResolvedKernelConfig } from '../../src/config/resolver.js'; +import { BuildSessionScope } from '../../src/services/session-scope.js'; +import { DiagnosticRegistry } from '../../src/services/diagnostics.js'; +import { SourceRegistry } from '../../src/services/sources.js'; +import { WatchRegistry } from '../../src/services/watch.js'; +import { ResourceRegistry } from '../../src/resources/registry.js'; + +/** Resource Registry 测试临时根。 */ +const roots: string[] = []; + +/** @returns 最小 Platform。 */ +function platform() { + return definePlatform({ + id: 'target', apiVersion: '1', deliveryType: 'plugin', + createSession: () => ({ + createPackage: () => ({ documents: [], assets: [], compatibility: [], metadata: [] }), + finalizePackage: () => ({ id: 'plugin', type: 'plugin' }), + validatePackage: () => undefined, + }), + }); +} + +/** @returns 声明 roots 的最小 Extension。 */ +function extension(id: string, resourceRoots: readonly string[]) { + return defineExtension({ + id, apiVersion: '1', resourceRoots, + createSession: () => ({ + discover: () => ({}), + validate: () => ({ state: {}, subjects: [] }), + build: () => ({ state: {} }), + contributors: [], + }), + }); +} + +/** + * 创建可 claim 的临时工程和 Registry。 + * + * @param input runtime 与 extensions 配置。 + * @returns 当前测试 BuildSession fixture。 + */ +async function fixture(input: { readonly runtime?: false; readonly extensions?: readonly ReturnType[] } = {}) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-resource-registry-')); + roots.push(root); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), 'export default {};\n'); + /** 配置先走最终 Kernel resolver。 */ + const resolved = resolveKernelConfig({ + name: 'resource-fixture', version: '1.0.0', description: 'Resource fixture.', platforms: [platform()], + ...(input.runtime === undefined ? {} : { runtime: input.runtime }), + ...(input.extensions === undefined ? {} : { extensions: input.extensions }), + }, { projectRoot: root, configFile: path.join(root, 'acplugin.config.ts'), command: 'build', mode: 'production' }); + expect(resolved.diagnostics).toEqual([]); + const scope = new BuildSessionScope(); + const sources = new SourceRegistry(scope, root); + const watch = new WatchRegistry(scope, root); + const diagnostics = new DiagnosticRegistry(); + const registry = new ResourceRegistry({ config: resolved.config as ResolvedKernelConfig, sources, watch, diagnostics }); + return { root, sources, watch, diagnostics, registry }; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('ResourceRegistry', () => { + it('claims canonical, Runtime and configured Extension roots generically', async () => { + const current = await fixture({ extensions: [extension('hooks', ['hooks']), extension('mcp', ['mcp'])] }); + await fs.mkdir(path.join(current.root, 'src', 'commands'), { recursive: true }); + await fs.mkdir(path.join(current.root, 'src', 'runtime'), { recursive: true }); + await fs.mkdir(path.join(current.root, 'src', 'hooks'), { recursive: true }); + await fs.mkdir(path.join(current.root, 'src', 'mcp'), { recursive: true }); + await fs.writeFile(path.join(current.root, 'src', 'hooks', 'hook.ts'), 'export {};\n'); + await fs.writeFile(path.join(current.root, 'src', 'mcp', 'mcp.ts'), 'export {};\n'); + + const claims = await current.registry.claim(); + expect(current.diagnostics.diagnostics).toEqual([]); + expect(claims.canonical.commands?.path).toBe('src/commands'); + expect(claims.runtime?.path).toBe('src/runtime'); + expect(claims.extensions.hooks?.hooks?.path).toBe('src/hooks'); + expect(claims.extensions.mcp?.mcp?.path).toBe('src/mcp'); + expect(Object.isFrozen(claims.extensions)).toBe(true); + expect(current.watch.snapshot().identities).toContain('src'); + }); + + it('rejects unknown non-empty roots and direct files while ignoring unknown empty directories', async () => { + const current = await fixture(); + await fs.mkdir(path.join(current.root, 'src', 'empty'), { recursive: true }); + await fs.mkdir(path.join(current.root, 'src', 'unknown'), { recursive: true }); + await fs.writeFile(path.join(current.root, 'src', 'unknown', 'value.ts'), 'export {};\n'); + await fs.writeFile(path.join(current.root, 'src', 'loose.ts'), 'export {};\n'); + + await current.registry.claim(); + const unknown = current.diagnostics.diagnostics.filter(item => item.code === 'RESOURCE_ROOT_UNKNOWN'); + expect(unknown.map(item => item.location?.path)).toEqual(['src/loose.ts', 'src/unknown']); + }); + + it('uses the same unknown-root gate for disabled Runtime and unconfigured horizontal resources', async () => { + const current = await fixture({ runtime: false }); + for (const directory of ['runtime', 'hooks', 'mcp']) { + await fs.mkdir(path.join(current.root, 'src', directory), { recursive: true }); + await fs.writeFile(path.join(current.root, 'src', directory, 'entry.ts'), 'export {};\n'); + } + + await current.registry.claim(); + expect(current.diagnostics.diagnostics.filter(item => item.code === 'RESOURCE_ROOT_UNKNOWN').map(item => item.location?.path)).toEqual([ + 'src/hooks', 'src/mcp', 'src/runtime', + ]); + }); + + it('rejects duplicate Extension claims and unsafe author roots without product-name branches', async () => { + const current = await fixture({ extensions: [extension('first', ['shared']), extension('second', ['shared'])] }); + await fs.mkdir(path.join(current.root, 'src'), { recursive: true }); + await fs.mkdir(path.join(current.root, 'outside'), { recursive: true }); + await fs.symlink(path.join(current.root, 'outside'), path.join(current.root, 'src', 'shared'), 'dir'); + + await current.registry.claim(); + expect(current.diagnostics.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'RESOURCE_ROOT_CONFLICT' }), + expect.objectContaining({ code: 'SOURCE_ROOT_CONTENT_INVALID' }), + ])); + }); +}); diff --git a/packages/core/test/resources/runtime-paths.test.ts b/packages/core/test/resources/runtime-paths.test.ts new file mode 100644 index 0000000..0f8d135 --- /dev/null +++ b/packages/core/test/resources/runtime-paths.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { + nodeRuntimeArtifactPath, + nodeRuntimeLicensesArtifactPath, +} from '../../src/resources/runtime/paths.js'; + +describe('Node Runtime paths', () => { + it('returns fixed predictable Package paths for valid entry IDs', () => { + expect(nodeRuntimeArtifactPath('llmdoc')).toBe('runtime/llmdoc/main.mjs'); + expect(nodeRuntimeLicensesArtifactPath('local-tools')).toBe('runtime/local-tools/THIRD_PARTY_LICENSES.txt'); + }); + + it('rejects values that are not canonical Runtime entry IDs', () => { + for (const id of ['', 'Invalid', '../escape', 'nested/entry', 'cafe\u0301']) { + expect(() => nodeRuntimeArtifactPath(id)).toThrow('lowercase kebab-case'); + expect(() => nodeRuntimeLicensesArtifactPath(id)).toThrow('lowercase kebab-case'); + } + }); +}); diff --git a/packages/core/test/resources/runtime-provider.test.ts b/packages/core/test/resources/runtime-provider.test.ts new file mode 100644 index 0000000..19188dc --- /dev/null +++ b/packages/core/test/resources/runtime-provider.test.ts @@ -0,0 +1,169 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { BuildSessionScope } from '../../src/services/session-scope.js'; +import { DiagnosticRegistry } from '../../src/services/diagnostics.js'; +import { SourceRegistry } from '../../src/services/sources.js'; +import { discoverNodeRuntime } from '../../src/resources/runtime/provider.js'; + +/** Runtime Provider 测试根。 */ +const roots: string[] = []; + +/** + * 创建 Runtime root 和 Source Registry。 + * + * @returns 当前测试 fixture。 + */ +async function fixture() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-runtime-provider-')); + roots.push(root); + const runtime = path.join(root, 'src', 'runtime'); + await fs.mkdir(runtime, { recursive: true }); + const scope = new BuildSessionScope(); + const sources = new SourceRegistry(scope, root); + const diagnostics = new DiagnosticRegistry(); + return { root, runtime, sources, diagnostics }; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('Runtime Provider', () => { + it('auto-discovers only direct executable TS/JS sources and keeps nested modules as dependencies', async () => { + const current = await fixture(); + await fs.mkdir(path.join(current.runtime, 'internal'), { recursive: true }); + await fs.writeFile(path.join(current.runtime, 'cli.ts'), 'export {};\n'); + await fs.writeFile(path.join(current.runtime, 'worker.mts'), 'export {};\n'); + await fs.writeFile(path.join(current.runtime, 'types.d.ts'), 'export interface Type {}\n'); + await fs.writeFile(path.join(current.runtime, 'internal', 'helper.ts'), 'export {};\n'); + const runtimeRoot = await current.sources.issueRoot('framework:node-runtime', current.runtime); + const resource = await discoverNodeRuntime({ + root: runtimeRoot, + config: { enabled: true, directory: current.runtime, target: 'node20' }, + sources: current.sources, + diagnostics: current.diagnostics, + }); + + expect(current.diagnostics.diagnostics).toEqual([]); + expect(resource?.entries.map(entry => [entry.id, entry.kind, entry.source.path])).toEqual([ + ['cli', 'executable', 'src/runtime/cli.ts'], + ['worker', 'executable', 'src/runtime/worker.mts'], + ]); + expect(JSON.stringify(resource)).not.toContain(current.root); + expect(Object.isFrozen(resource?.entries)).toBe(true); + }); + + it('uses explicit entries as a complete replacement and preserves compile options', async () => { + const current = await fixture(); + await fs.mkdir(path.join(current.runtime, 'bin'), { recursive: true }); + await fs.writeFile(path.join(current.runtime, 'ignored.ts'), 'export {};\n'); + await fs.writeFile(path.join(current.runtime, 'bin', 'cli.ts'), 'export {};\n'); + const runtimeRoot = await current.sources.issueRoot('framework:node-runtime', current.runtime); + const resource = await discoverNodeRuntime({ + root: runtimeRoot, + config: { + enabled: true, + directory: current.runtime, + target: 'node20', + entries: { tool: { entry: 'bin/cli.ts', kind: 'module' } }, + compile: { treeshake: false }, + }, + sources: current.sources, + diagnostics: current.diagnostics, + }); + + expect(current.diagnostics.diagnostics).toEqual([]); + expect(resource).toMatchObject({ target: 'node20', compile: { treeshake: false } }); + expect(resource?.entries.map(entry => [entry.id, entry.kind, entry.source.path])).toEqual([ + ['tool', 'module', 'src/runtime/bin/cli.ts'], + ]); + }); + + it('reports unsupported, duplicate and missing entries without fake Runtime output', async () => { + const current = await fixture(); + await fs.writeFile(path.join(current.runtime, 'cli.ts'), 'export {};\n'); + await fs.writeFile(path.join(current.runtime, 'cli.js'), 'export {};\n'); + await fs.writeFile(path.join(current.runtime, 'README.md'), 'not runtime\n'); + const runtimeRoot = await current.sources.issueRoot('framework:node-runtime', current.runtime); + const automatic = await discoverNodeRuntime({ + root: runtimeRoot, + config: { enabled: true, directory: current.runtime, target: 'node20' }, + sources: current.sources, + diagnostics: current.diagnostics, + }); + expect(automatic?.entries).toHaveLength(1); + expect(current.diagnostics.diagnostics.map(item => item.code)).toEqual(expect.arrayContaining([ + 'RUNTIME_ENTRY_CONFLICT', 'RUNTIME_SOURCE_UNSUPPORTED', + ])); + + const explicitDiagnostics = new DiagnosticRegistry(); + const explicit = await discoverNodeRuntime({ + root: runtimeRoot, + config: { + enabled: true, + directory: current.runtime, + target: 'node20', + entries: { + missing: { entry: 'missing.ts', kind: 'executable' }, + declarations: { entry: 'types.d.ts', kind: 'module' }, + }, + }, + sources: current.sources, + diagnostics: explicitDiagnostics, + }); + expect(explicit).toBeUndefined(); + expect(explicitDiagnostics.diagnostics.map(item => item.code)).toEqual(['RUNTIME_ENTRY_MISSING', 'RUNTIME_SOURCE_UNSUPPORTED']); + }); + + it('defensively rejects invalid IDs, escaped paths and symlink sources', async () => { + const current = await fixture(); + await fs.writeFile(path.join(current.runtime, 'valid.ts'), 'export {};\n'); + await fs.writeFile(path.join(current.root, 'outside.ts'), 'export {};\n'); + await fs.symlink(path.join(current.root, 'outside.ts'), path.join(current.runtime, 'linked.ts')); + const runtimeRoot = await current.sources.issueRoot('framework:node-runtime', current.runtime); + const resource = await discoverNodeRuntime({ + root: runtimeRoot, + config: { + enabled: true, + directory: current.runtime, + target: 'node20', + entries: { + Valid: { entry: 'valid.ts', kind: 'module' }, + cafe\u0301: { entry: 'valid.ts', kind: 'module' }, + escaped: { entry: '../outside.ts', kind: 'module' }, + linked: { entry: 'linked.ts', kind: 'module' }, + }, + }, + sources: current.sources, + diagnostics: current.diagnostics, + }); + + expect(resource).toBeUndefined(); + expect(current.diagnostics.diagnostics.map(item => item.code)).toEqual([ + 'RUNTIME_ENTRY_MISSING', + 'RUNTIME_ENTRY_MISSING', + 'RUNTIME_ENTRY_ID_INVALID', + 'RUNTIME_ENTRY_ID_INVALID', + ]); + expect(JSON.stringify(current.diagnostics.diagnostics)).not.toContain(current.root); + }); + + it('is silent for absent, empty and explicitly unselected Runtime roots', async () => { + const current = await fixture(); + expect(await discoverNodeRuntime({ + config: { enabled: true, directory: current.runtime, target: 'node20' }, + sources: current.sources, + diagnostics: current.diagnostics, + })).toBeUndefined(); + const runtimeRoot = await current.sources.issueRoot('framework:node-runtime', current.runtime); + expect(await discoverNodeRuntime({ + root: runtimeRoot, + config: { enabled: true, directory: current.runtime, target: 'node20', entries: {} }, + sources: current.sources, + diagnostics: current.diagnostics, + })).toBeUndefined(); + expect(current.diagnostics.diagnostics).toEqual([]); + }); +}); diff --git a/packages/core/test/services/asset-registry.test.ts b/packages/core/test/services/asset-registry.test.ts new file mode 100644 index 0000000..7b02ba3 --- /dev/null +++ b/packages/core/test/services/asset-registry.test.ts @@ -0,0 +1,315 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { AssetRef, PackageComponentOrigin } from '../../src/api/integration.js'; +import { AssetRegistry } from '../../src/services/assets.js'; +import { BuildSessionScope } from '../../src/services/session-scope.js'; +import { SourceRegistry } from '../../src/services/sources.js'; +import { WorkDirectoryRegistry } from '../../src/services/work-directories.js'; + +/** Asset Registry 测试创建的临时根。 */ +const roots: string[] = []; + +/** + * 创建一套共享同一 BuildSession 的 Source/Work/Asset Registry。 + * + * @returns 测试 fixture 和 Registry 集合。 + */ +async function registries() { + /** 当前测试独占工程根。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-asset-registry-')); + roots.push(root); + /** 作者来源根。 */ + const sourceRoot = path.join(root, 'src', 'owned'); + await fs.mkdir(sourceRoot, { recursive: true }); + await fs.writeFile(path.join(sourceRoot, 'source.txt'), 'source bytes\n'); + /** Core 内部 workDir 父目录。 */ + const workRoot = path.join(root, '.work'); + /** 当前 BuildSession capability scope。 */ + const scope = new BuildSessionScope(); + /** 当前 Session Source Registry。 */ + const sources = new SourceRegistry(scope, root); + /** 当前 Session WorkDir Registry。 */ + const work = new WorkDirectoryRegistry(scope, workRoot); + /** 当前 Session Asset Registry。 */ + const assets = new AssetRegistry(scope, sources, work); + return { root, sourceRoot, scope, sources, work, assets }; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('AssetRegistry', () => { + it('copies bytes, binds owner and reports stable generated provenance', async () => { + /** 当前测试独占的 Registry 集合。 */ + const fixture = await registries(); + /** 调用方保留并将在签发后修改的原始字节。 */ + const input = Uint8Array.of(1, 2, 3); + /** extension owner 的闭包 Asset Service。 */ + const service = fixture.assets.service('extension:owned'); + /** 带稳定 operation/subjects 的内存 Asset。 */ + const asset = await service.fromBytes({ + bytes: input, + mode: 0o755, + origin: { operation: 'hook-runner', subjects: ['hook:beta', 'hook:alpha'] }, + }); + input[0] = 9; + /** 对外报告不包含私有 bytes 或物理路径。 */ + const description = fixture.assets.describe('extension:owned', asset); + + expect([...await service.read(asset)]).toEqual([1, 2, 3]); + expect(description.owner).toBe('extension:owned'); + expect(description.mode).toBe(0o755); + expect(description.sha256).toBe('039058c6f2c0cb492c533b0a4d14ef77cc0f78abccced5287d84a1a2011cfb81'); + expect(description.origin).toEqual({ + type: 'generated', + owner: 'extension:owned', + operation: 'hook-runner', + subjects: ['hook:alpha', 'hook:beta'], + }); + expect(JSON.stringify(asset)).not.toContain(fixture.root); + }); + + it('rejects coercible, unknown-field and behavior-bearing Bytes Asset inputs', async () => { + const fixture = await registries(); + const service = fixture.assets.service('extension:owned'); + + await expect(service.fromBytes({ + bytes: [1, 2, 3], origin: { operation: 'array-like' }, + } as never)).rejects.toThrow('string or Uint8Array'); + await expect(service.fromBytes({ + bytes: 'hidden', origin: { operation: 'unknown-input' }, hidden: true, + } as never)).rejects.toThrow('Bytes Asset input contains unknown field "hidden"'); + await expect(service.fromBytes({ + bytes: 'hidden', origin: { operation: 'unknown-origin', hidden: true }, + } as never)).rejects.toThrow('Generated Asset origin contains unknown field "hidden"'); + + let getterCalls = 0; + const accessorSubjects: string[] = []; + Object.defineProperty(accessorSubjects, '0', { + enumerable: true, + get: () => { + getterCalls += 1; + return 'fixture:subject'; + }, + }); + accessorSubjects.length = 1; + await expect(service.fromBytes({ + bytes: 'accessor', origin: { operation: 'accessor-subjects', subjects: accessorSubjects }, + })).rejects.toThrow('dense'); + expect(getterCalls).toBe(0); + await expect(service.fromBytes({ + bytes: 'sparse', origin: { operation: 'sparse-subjects', subjects: new Array(1) }, + })).rejects.toThrow('dense'); + }); + + it('uses owner-local ref identities that do not reveal cross-owner scheduling', async () => { + /** 第一轮 Registry 模拟 owner-b 先完成。 */ + const first = await registries(); + /** 第一轮先由 owner-b 签发,模拟相反并发完成顺序。 */ + await first.assets.service('extension:b').fromBytes({ bytes: 'b', origin: { operation: 'build' } }); + /** 第一轮 owner-a 的首个 ref。 */ + const firstA = await first.assets.service('extension:a').fromBytes({ bytes: 'a', origin: { operation: 'build' } }); + /** 第二轮 Registry 模拟 owner-a 先完成。 */ + const second = await registries(); + /** 第二轮 owner-a 先签发。 */ + const secondA = await second.assets.service('extension:a').fromBytes({ bytes: 'a', origin: { operation: 'build' } }); + + expect(firstA.id).toBe(secondA.id); + }); + + it('accepts Component provenance only through the current finalization scope and revokes it afterward', async () => { + const fixture = await registries(); + /** Core registry signs origin identity during Package merge. */ + const alpha = fixture.assets.issueComponentOrigin('target', 'extension:alpha', 'private:alpha'); + const zeta = fixture.assets.issueComponentOrigin('target', 'extension:zeta', 'private:zeta'); + const scope = fixture.assets.componentFinalizationScope('target', 'platform:target', [{ origin: zeta }, { origin: alpha }]); + const asset = await scope.service.fromBytes({ + bytes: 'rendered\n', + origin: { + operation: 'platform-component', + subjects: ['private:zeta', 'private:alpha'], + componentOrigins: [zeta, alpha], + }, + }); + expect(fixture.assets.describe('platform:target', asset).origin).toEqual({ + type: 'generated', owner: 'platform:target', operation: 'platform-component', + contributors: [ + { owner: 'extension:alpha', subject: 'private:alpha' }, + { owner: 'extension:zeta', subject: 'private:zeta' }, + ], + subjects: ['private:alpha', 'private:zeta'], + }); + await expect(scope.service.fromBytes({ + bytes: 'mismatched', + origin: { + operation: 'platform-component', + subjects: ['private:zeta'], + componentOrigins: [alpha], + }, + })).rejects.toThrow('is not declared by its Component origins'); + await expect(scope.service.fromBytes({ + bytes: 'forged', origin: { operation: 'platform-component', componentOrigins: [Object.freeze({ owner: 'extension:alpha', subject: 'private:alpha' }) as PackageComponentOrigin] }, + })).rejects.toThrow('not authorized'); + await expect(scope.service.fromBytes({ + bytes: 'duplicate', origin: { operation: 'platform-component', componentOrigins: [alpha, alpha] }, + })).resolves.toBeDefined(); + let getterCalls = 0; + const accessorOrigins: PackageComponentOrigin[] = []; + Object.defineProperty(accessorOrigins, '0', { + enumerable: true, + get: () => { + getterCalls += 1; + return alpha; + }, + }); + accessorOrigins.length = 1; + await expect(scope.service.fromBytes({ + bytes: 'accessor', origin: { operation: 'platform-component', componentOrigins: accessorOrigins }, + })).rejects.toThrow('dense'); + expect(getterCalls).toBe(0); + const sparseOrigins = new Array(1); + await expect(scope.service.fromBytes({ + bytes: 'sparse', origin: { operation: 'platform-component', componentOrigins: sparseOrigins }, + })).rejects.toThrow('dense'); + await expect(fixture.assets.service('platform:target').fromBytes({ + bytes: 'outside', origin: { operation: 'platform-component', componentOrigins: [alpha] } as never, + })).rejects.toThrow('only available during Platform finalization'); + scope.close(); + await expect(scope.service.fromBytes({ bytes: 'late', origin: { operation: 'late' } })).rejects.toThrow('no longer active'); + }); + + it('rejects Component origins from another Platform or BuildSession', async () => { + const current = await registries(); + const otherPlatform = current.assets.issueComponentOrigin('other', 'extension:private', 'private:resource'); + expect(() => current.assets.componentFinalizationScope( + 'target', + 'platform:target', + [{ origin: otherPlatform }], + )).toThrow('not authorized'); + + const otherSession = await registries(); + const otherOrigin = otherSession.assets.issueComponentOrigin('target', 'extension:private', 'private:resource'); + expect(() => current.assets.componentFinalizationScope( + 'target', + 'platform:target', + [{ origin: otherOrigin }], + )).toThrow('not authorized'); + }); + + it('creates SourceAsset refs and rejects forged, cross-owner, cross-session and expired refs', async () => { + /** 当前测试独占的 Registry 集合。 */ + const fixture = await registries(); + /** 当前 owner 的来源目录 ref。 */ + const root = await fixture.sources.issueRoot('framework:canonical', fixture.sourceRoot); + /** 当前 owner 的来源服务。 */ + const sources = fixture.sources.service('framework:canonical'); + /** 精确来源文件 ref。 */ + const source = await sources.file(root, 'source.txt'); + /** 来源 owner 的 Asset Service。 */ + const service = fixture.assets.service('framework:canonical'); + /** 从 SourceFileRef 签发的来源 Asset。 */ + const asset = await service.fromSource(source); + /** 等形复制不在 WeakMap 中。 */ + const forged = Object.freeze({ ...asset }) as AssetRef; + + await expect(fixture.assets.service('platform:claude-code').read(asset)).rejects.toThrow('not authorized'); + await expect(service.read(forged)).rejects.toThrow('not authorized'); + fixture.assets.grant('framework:canonical', 'platform:claude-code', asset); + await expect(fixture.assets.service('platform:claude-code').read(asset)).resolves.toEqual(expect.any(Uint8Array)); + expect(() => fixture.assets.grant('platform:claude-code', 'extension:other', asset)).toThrow('Only the Asset owner'); + + /** 另一 BuildSession 的 Registry 即使收到原始对象也无记录。 */ + const otherScope = new BuildSessionScope(); + /** 另一 Session 的 Source Registry。 */ + const otherSources = new SourceRegistry(otherScope, fixture.root); + /** 另一 Session 的 WorkDir Registry。 */ + const otherWork = new WorkDirectoryRegistry(otherScope, path.join(fixture.root, '.work-other')); + /** 另一 Session 的 Asset Registry。 */ + const otherAssets = new AssetRegistry(otherScope, otherSources, otherWork); + await expect(otherAssets.service('framework:canonical').read(asset)).rejects.toThrow('not authorized'); + + fixture.scope.close(); + await expect(service.read(asset)).rejects.toThrow('no longer active'); + }); + + it('detects SourceAsset mutation before read and materialization', async () => { + /** 当前测试独占的 Registry 集合。 */ + const fixture = await registries(); + /** 当前 owner 的来源目录 ref。 */ + const root = await fixture.sources.issueRoot('framework:public', fixture.sourceRoot); + /** 当前 owner 的精确来源文件 ref。 */ + const source = await fixture.sources.service('framework:public').file(root, 'source.txt'); + /** 当前 owner 的 SourceAsset。 */ + const asset = await fixture.assets.service('framework:public').fromSource(source); + await fs.writeFile(path.join(fixture.sourceRoot, 'source.txt'), 'changed bytes\n'); + + await expect(fixture.assets.service('framework:public').read(asset)).rejects.toThrow('changed after'); + await expect(fixture.assets.materializationBytes('framework:public', asset)).rejects.toThrow('changed after'); + }); + + it('rejects source mutation between FileRef and SourceAsset issuance', async () => { + /** 当前测试独占的 Registry 集合。 */ + const fixture = await registries(); + /** 当前 owner 的来源目录 ref。 */ + const root = await fixture.sources.issueRoot('framework:public', fixture.sourceRoot); + /** 修改前签发的精确 SourceFileRef。 */ + const source = await fixture.sources.service('framework:public').file(root, 'source.txt'); + await fs.writeFile(path.join(fixture.sourceRoot, 'source.txt'), 'changed before asset\n'); + + await expect(fixture.assets.service('framework:public').fromSource(source)).rejects.toThrow('changed after'); + }); + + it('issues GeneratedAsset only from the owner workDir and preserves compile origin', async () => { + /** 当前测试独占的 Registry 集合。 */ + const fixture = await registries(); + /** Compiler owner 的私有 workDir 句柄。 */ + const work = await fixture.work.directory('extension:mcp'); + /** 只有 Core Host 能获得的解析后生成路径。 */ + const output = fixture.work.resolve('extension:mcp', work, 'jobs/server/main.mjs'); + await fs.mkdir(path.dirname(output), { recursive: true }); + await fs.writeFile(output, 'export default 1;\n'); + /** Compiler Host 签发的 GeneratedAsset。 */ + const asset = await fixture.assets.issueGenerated('extension:mcp', work, 'jobs/server/main.mjs', 0o755, { + job: 'mcp-server', + output: 'main', + profile: 'portable-node', + kind: 'chunk', + inputs: ['src/mcp/server/server.ts', 'package:@scope/dependency@1.2.3/index.js', 'virtual:mcp-runner'], + }); + /** 公开 metadata 保留稳定编译来源但没有 workDir。 */ + const description = fixture.assets.describe('extension:mcp', asset); + + expect(description.origin).toEqual({ + type: 'compile', + owner: 'extension:mcp', + job: 'mcp-server', + output: 'main', + profile: 'portable-node', + kind: 'chunk', + inputs: ['package:@scope/dependency@1.2.3/index.js', 'src/mcp/server/server.ts', 'virtual:mcp-runner'], + }); + expect(JSON.stringify(description)).not.toContain(fixture.root); + await fs.writeFile(output, 'mutated\n'); + await expect(fixture.assets.materializationBytes('extension:mcp', asset)).rejects.toThrow('changed after'); + }); + + it('rejects invalid modes, origin text, read limits and workDir escapes', async () => { + /** 当前测试独占的 Registry 集合。 */ + const fixture = await registries(); + /** 当前 owner 的闭包 Asset Service。 */ + const service = fixture.assets.service('extension:owned'); + await expect(service.fromBytes({ bytes: 'x', mode: 0o600 as never, origin: { operation: 'valid' } })).rejects.toThrow('0644 or 0755'); + await expect(service.fromBytes({ bytes: 'x', origin: { operation: '/absolute/path' } })).rejects.toThrow('stable lowercase'); + /** 合法 Asset 用于读取上限断言。 */ + const asset = await service.fromBytes({ bytes: 'abc', origin: { operation: 'valid' } }); + await expect(service.read(asset, { maxBytes: 2 })).rejects.toThrow('read limit'); + + /** owner workDir 句柄不能被另一 owner 复用。 */ + const work = await fixture.work.directory('extension:a'); + expect(() => fixture.work.resolve('extension:b', work, 'main.mjs')).toThrow('not authorized'); + expect(() => fixture.work.resolve('extension:a', work, '../escape.mjs')).toThrow(); + }); +}); diff --git a/packages/core/test/services/execution-host.test.ts b/packages/core/test/services/execution-host.test.ts new file mode 100644 index 0000000..70a4588 --- /dev/null +++ b/packages/core/test/services/execution-host.test.ts @@ -0,0 +1,180 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { AssetMode, GeneratedAssetRef } from '../../src/contracts/index.js'; +import { CompilerHost } from '../../src/compiler/compiler-service.js'; +import { AssetRegistry } from '../../src/services/assets.js'; +import { BuildSessionScope } from '../../src/services/session-scope.js'; +import { ExecutionHost } from '../../src/services/execution.js'; +import { SourceRegistry } from '../../src/services/sources.js'; +import { WatchRegistry } from '../../src/services/watch.js'; +import { WorkDirectoryRegistry } from '../../src/services/work-directories.js'; + +/** Execution Host 测试创建的临时工程根。 */ +const roots: string[] = []; + +/** + * 创建 Compiler + Execution 共用的一轮 BuildSession。 + * + * @param code portable-node 测试程序。 + * @param mode 入口 Asset mode。 + * @param id 编译 Job ID。 + * @returns 已签发入口和 owner-scoped Execution service。 + */ +async function fixture(code: string, mode: AssetMode = 0o755, id = 'execution-job') { + /** 当前测试独占工程根。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-execution-host-')); + roots.push(root); + /** portable 作者源码 root。 */ + const sourceRoot = path.join(root, 'src', 'runtime'); + await fs.mkdir(sourceRoot, { recursive: true }); + await fs.writeFile(path.join(sourceRoot, 'main.ts'), code); + /** 当前 BuildSession registries。 */ + const scope = new BuildSessionScope(); + const owner = 'extension:fixture'; + const sources = new SourceRegistry(scope, root); + const work = new WorkDirectoryRegistry(scope, path.join(root, '.work')); + const assets = new AssetRegistry(scope, sources, work); + const watch = new WatchRegistry(scope, root); + /** 当前 owner 作者来源入口。 */ + const directory = await sources.issueRoot(owner, sourceRoot); + const entry = await sources.service(owner).file(directory, 'main.ts'); + /** 用真实 portable Profile 生成 Execution Host 唯一接受的 Asset。 */ + const compiler = new CompilerHost({ projectRoot: root, sources, workDirectories: work, assets, watch }); + const result = await (await compiler.service(owner)).compile({ + id, + profile: 'portable-node', + entries: { main: { type: 'source', source: entry, mode } }, + }); + /** portable 单入口生成的 main Chunk。 */ + const asset = result.outputs.find(output => output.type === 'chunk')!.asset; + /** owner 物理根只供 Core 测试验证 cleanup。 */ + const handle = await work.directory(owner); + const ownerWorkRoot = work.physicalRoot(owner, handle); + /** 当前 BuildSession 唯一 Execution Host。 */ + const host = new ExecutionHost({ assets, workDirectories: work }); + return { root, owner, ownerWorkRoot, work, assets, asset, service: host.service(owner), other: host.service('extension:other') }; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('ExecutionHost', () => { + it('runs portable Node with literal args/stdin, minimal env and preserved mode', async () => { + /** 测试程序只观察显式输入和自身物化 mode。 */ + const current = await fixture([ + 'import { statSync } from "node:fs";', + 'const chunks: Buffer[] = [];', + 'for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));', + 'process.stdout.write(JSON.stringify({', + ' args: process.argv.slice(2),', + ' stdin: Buffer.concat(chunks).toString("utf8"),', + ' visible: process.env.VISIBLE ?? null,', + ' inherited: process.env.ACPLUGIN_TEST_SECRET ?? null,', + ' mode: statSync(new URL(import.meta.url)).mode & 0o777,', + '}));', + ].join('\n'), 0o644); + /** 宿主 secret 绝不能被最小环境继承。 */ + const previous = process.env.ACPLUGIN_TEST_SECRET; + process.env.ACPLUGIN_TEST_SECRET = 'must-not-leak'; + try { + const result = await current.service.runNode({ + entry: current.asset, + args: ['--literal', 'value with spaces'], + stdin: 'payload\n', + timeoutMs: 5_000, + maxOutputBytes: 16_384, + environment: { VISIBLE: 'explicit' }, + }); + expect(result.status).toBe('exited'); + expect(result.exitCode).toBe(0); + expect(JSON.parse(new TextDecoder().decode(result.stdout))).toEqual({ + args: ['--literal', 'value with spaces'], + stdin: 'payload\n', + visible: 'explicit', + inherited: null, + mode: 0o644, + }); + } finally { + if (previous === undefined) + delete process.env.ACPLUGIN_TEST_SECRET; + else + process.env.ACPLUGIN_TEST_SECRET = previous; + } + await expect(fs.access(path.join(current.ownerWorkRoot, 'execution', '1'))).rejects.toThrow(); + }); + + it('returns stable nonzero, signal, timeout and shared output-limit results', async () => { + /** 非零退出仍是可检查的稳定 result。 */ + const failed = await fixture('process.stderr.write("failure"); process.exitCode = 7;', 0o755, 'nonzero-job'); + await expect(failed.service.runNode({ entry: failed.asset, timeoutMs: 5_000, maxOutputBytes: 1024 })).resolves.toMatchObject({ + status: 'exited', + exitCode: 7, + signal: null, + }); + + /** 固定 timeout 必须终止并返回脱敏状态。 */ + const timed = await fixture('setInterval(() => undefined, 1_000);', 0o755, 'timeout-job'); + await expect(timed.service.runNode({ entry: timed.asset, timeoutMs: 50, maxOutputBytes: 1024 })).resolves.toMatchObject({ + status: 'timed-out', + exitCode: null, + }); + + /** stdout/stderr 共享同一个输出预算,越界 chunk 不进入返回值。 */ + const output = await fixture('process.stdout.write("x".repeat(2048)); process.stderr.write("y".repeat(2048));', 0o755, 'output-job'); + const limited = await output.service.runNode({ entry: output.asset, timeoutMs: 5_000, maxOutputBytes: 128 }); + expect(limited.status).toBe('output-limit'); + expect(limited.stdout.byteLength + limited.stderr.byteLength).toBeLessThanOrEqual(128); + + if (process.platform !== 'win32') { + /** POSIX 自发 signal 必须与 timeout 明确区分。 */ + const signaled = await fixture('process.kill(process.pid, "SIGTERM");', 0o755, 'signal-job'); + await expect(signaled.service.runNode({ entry: signaled.asset, timeoutMs: 5_000, maxOutputBytes: 1024 })).resolves.toMatchObject({ + status: 'signaled', + signal: 'SIGTERM', + }); + } + }); + + it('rejects forged, cross-owner, non-chunk and unsafe environment inputs', async () => { + /** 当前合法 portable entry。 */ + const current = await fixture('export {};'); + /** 复制公开字段不能伪造 Registry identity。 */ + const forged = Object.freeze({ ...current.asset }) as GeneratedAssetRef; + await expect(current.service.runNode({ entry: forged, timeoutMs: 1_000, maxOutputBytes: 1024 })).rejects.toThrow('not authorized'); + await expect(current.other.runNode({ entry: current.asset, timeoutMs: 1_000, maxOutputBytes: 1024 })).rejects.toThrow('not authorized'); + await expect(current.service.runNode({ + entry: current.asset, + timeoutMs: 1_000, + maxOutputBytes: 1024, + environment: { NODE_OPTIONS: '--require=./inject.cjs' }, + })).rejects.toThrow('unsafe'); + + /** 由 Core 签发但 provenance kind 不是 chunk 的 Asset。 */ + const handle = await current.work.directory(current.owner); + const licenseFile = current.work.resolve(current.owner, handle, 'manual/licenses.txt'); + await fs.mkdir(path.dirname(licenseFile), { recursive: true }); + await fs.writeFile(licenseFile, 'license\n'); + const licenses = await current.assets.issueGenerated(current.owner, handle, 'manual/licenses.txt', 0o644, { + job: 'portable-job', + output: 'main', + profile: 'portable-node', + kind: 'licenses', + inputs: ['src/runtime/main.ts'], + }); + await expect(current.service.runNode({ entry: licenses, timeoutMs: 1_000, maxOutputBytes: 1024 })).rejects.toThrow('generated chunk'); + }); + + it('cleans the isolated execution root when pre-spawn materialization fails', async () => { + /** 当前合法 portable entry 及其可预测 Compiler work 文件。 */ + const current = await fixture('export {};', 0o755, 'mutation-job'); + const generated = path.join(current.ownerWorkRoot, 'compile', 'mutation-job', 'main', 'main.mjs'); + await fs.writeFile(generated, 'mutated after issuance\n'); + + await expect(current.service.runNode({ entry: current.asset, timeoutMs: 1_000, maxOutputBytes: 1024 })).rejects.toThrow('changed after'); + await expect(fs.access(path.join(current.ownerWorkRoot, 'execution', '1'))).rejects.toThrow(); + }); +}); diff --git a/packages/core/test/services/source-registry.test.ts b/packages/core/test/services/source-registry.test.ts new file mode 100644 index 0000000..d3fa1a9 --- /dev/null +++ b/packages/core/test/services/source-registry.test.ts @@ -0,0 +1,191 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { BuildSessionScope } from '../../src/services/session-scope.js'; +import { SourcePathCollisionRegistry } from '../../src/security/path-policy.js'; +import { SourceRegistry } from '../../src/services/sources.js'; + +/** Source Registry 测试创建的临时工程根。 */ +const roots: string[] = []; + +/** + * 创建包含 src/owned 的临时工程。 + * + * @returns 工程根和 Source root 绝对路径。 + */ +async function project(): Promise<{ readonly root: string; readonly sourceRoot: string }> { + /** 当前测试独占的工程根。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-source-registry-')); + roots.push(root); + /** Extension 独占的作者来源根。 */ + const sourceRoot = path.join(root, 'src', 'owned'); + await fs.mkdir(path.join(sourceRoot, 'nested'), { recursive: true }); + await fs.writeFile(path.join(sourceRoot, 'alpha.ts'), 'export const alpha = 1;\n'); + await fs.writeFile(path.join(sourceRoot, 'nested', 'beta.ts'), 'export const beta = 2;\n'); + return { root, sourceRoot }; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('SourceRegistry', () => { + it('issues safe refs, lists deterministically and reads exact source bytes', async () => { + /** 当前测试独占的临时工程。 */ + const fixture = await project(); + /** 本轮独占 capability scope。 */ + const scope = new BuildSessionScope(); + /** 当前工程唯一 Source Registry。 */ + const registry = new SourceRegistry(scope, fixture.root); + /** owner-scoped 根目录能力。 */ + const root = await registry.issueRoot('extension:owned', fixture.sourceRoot); + /** owner 无法填写或修改的闭包服务。 */ + const sources = registry.service('extension:owned'); + /** 递归列表必须使用工程相对路径而非绝对路径。 */ + const entries = await sources.list(root, { recursive: true }); + /** 非递归列表只能包含直接子项。 */ + const directEntries = await sources.list(root); + /** 从 root 精确签发的文件 ref。 */ + const alpha = await sources.file(root, 'alpha.ts'); + + expect(root.path).toBe('src/owned'); + expect(entries.map(entry => entry.path)).toEqual([ + 'src/owned/alpha.ts', + 'src/owned/nested', + 'src/owned/nested/beta.ts', + ]); + expect(directEntries.map(entry => entry.path)).toEqual(['src/owned/alpha.ts', 'src/owned/nested']); + expect(entries.every(entry => !path.isAbsolute(entry.path))).toBe(true); + expect(await sources.readText(alpha)).toBe('export const alpha = 1;\n'); + expect(Object.isFrozen(root)).toBe(true); + expect(Object.isFrozen(entries)).toBe(true); + }); + + it('rejects ambiguous paths, forged refs, cross-owner refs and expired sessions', async () => { + /** 当前测试独占的临时工程。 */ + const fixture = await project(); + /** 当前测试独占 capability scope。 */ + const scope = new BuildSessionScope(); + /** 当前工程唯一 Source Registry。 */ + const registry = new SourceRegistry(scope, fixture.root); + /** extension-a 的来源根。 */ + const root = await registry.issueRoot('extension:a', fixture.sourceRoot); + /** extension-a 的闭包服务。 */ + const owner = registry.service('extension:a'); + /** extension-b 不应得到 a 的 ref 权限。 */ + const other = registry.service('extension:b'); + /** 正式签发的文件 ref。 */ + const file = await owner.file(root, 'alpha.ts'); + /** 复制公共字段和 Symbol 也不在 Registry WeakMap 中。 */ + const forged = Object.freeze({ ...file }) as typeof file; + + for (const invalid of ['/absolute.ts', '../escape.ts', './dot.ts', 'nested//file.ts', 'nested\\file.ts']) + await expect(owner.file(root, invalid)).rejects.toThrow(); + await expect(owner.read(forged)).rejects.toThrow('not authorized'); + await expect(other.read(file)).rejects.toThrow('not authorized'); + scope.close(); + await expect(owner.read(file)).rejects.toThrow('no longer active'); + }); + + it('rejects author symlinks and source mutation into a symlink', async () => { + /** 当前测试独占的临时工程。 */ + const fixture = await project(); + /** 当前测试独占 capability scope。 */ + const scope = new BuildSessionScope(); + /** 当前工程唯一 Source Registry。 */ + const registry = new SourceRegistry(scope, fixture.root); + /** 合法作者来源根。 */ + const root = await registry.issueRoot('extension:owned', fixture.sourceRoot); + /** 当前 owner 的闭包服务。 */ + const sources = registry.service('extension:owned'); + /** 首次签发时仍为普通文件。 */ + const file = await sources.file(root, 'alpha.ts'); + /** 工程外目标用于验证 symlink 逃逸。 */ + const outside = path.join(fixture.root, 'outside.ts'); + await fs.writeFile(outside, 'secret\n'); + await fs.symlink(outside, path.join(fixture.sourceRoot, 'link.ts')); + + await expect(sources.list(root)).rejects.toThrow('symbolic links'); + await fs.rm(path.join(fixture.sourceRoot, 'alpha.ts')); + await fs.symlink(outside, path.join(fixture.sourceRoot, 'alpha.ts')); + await expect(sources.read(file)).rejects.toThrow('symbolic links'); + }); + + it('rejects ordinary content mutation after a FileRef was issued', async () => { + /** 当前测试独占的临时工程。 */ + const fixture = await project(); + /** 当前测试独占 capability scope。 */ + const scope = new BuildSessionScope(); + /** 当前工程唯一 Source Registry。 */ + const registry = new SourceRegistry(scope, fixture.root); + /** 当前 owner 的来源根和服务。 */ + const root = await registry.issueRoot('extension:owned', fixture.sourceRoot); + /** 当前 owner 的闭包 Source Service。 */ + const sources = registry.service('extension:owned'); + /** 内容修改前签发的 FileRef。 */ + const file = await sources.file(root, 'alpha.ts'); + await fs.writeFile(path.join(fixture.sourceRoot, 'alpha.ts'), 'export const alpha = 2;\n'); + + await expect(sources.read(file)).rejects.toThrow('changed after'); + }); + + it('rejects special files and enforces bounded strict UTF-8 reads', async () => { + if (process.platform === 'win32') + return; + /** 当前测试独占的临时工程。 */ + const fixture = await project(); + /** FIFO 路径用于验证非普通文件拒绝。 */ + const fifo = path.join(fixture.sourceRoot, 'pipe'); + /** 使用 mkfifo 创建不会被 readdir Dirent 误判为普通文件的 fixture。 */ + const { execFile } = await import('node:child_process'); + await new Promise((resolve, reject) => execFile('mkfifo', [fifo], error => error ? reject(error) : resolve())); + /** 无效 UTF-8 文件用于验证 fatal decoder。 */ + await fs.writeFile(path.join(fixture.sourceRoot, 'invalid.bin'), Uint8Array.of(0xC3, 0x28)); + /** 当前测试独占 capability scope。 */ + const scope = new BuildSessionScope(); + /** 当前工程唯一 Source Registry。 */ + const registry = new SourceRegistry(scope, fixture.root); + /** 合法作者来源根。 */ + const root = await registry.issueRoot('extension:owned', fixture.sourceRoot); + /** 当前 owner 的闭包服务。 */ + const sources = registry.service('extension:owned'); + /** 无效 UTF-8 文件仍可作为字节来源签发。 */ + const invalid = await sources.file(root, 'invalid.bin'); + + await expect(sources.list(root)).rejects.toThrow('regular files'); + await expect(sources.read(invalid, { maxBytes: 1 })).rejects.toThrow('read limit'); + await expect(sources.readText(invalid)).rejects.toThrow(); + }); + + it('detects exact, case-folded and Unicode NFC source collisions without locale rules', () => { + /** 独立 collision registry 能验证当前大小写敏感文件系统不易创建的 fixture。 */ + const collisions = new SourcePathCollisionRegistry(); + collisions.reserve('src/owned/Foo.ts', '/physical/Foo.ts'); + expect(() => collisions.reserve('src/owned/foo.ts', '/physical/foo.ts')).toThrow('collision'); + + /** 第二个 registry 隔离 Unicode 归一化场景。 */ + const unicode = new SourcePathCollisionRegistry(); + unicode.reserve('src/owned/caf\u00e9.ts', '/physical/composed.ts'); + expect(() => unicode.reserve('src/owned/cafe\u0301.ts', '/physical/decomposed.ts')).toThrow('collision'); + }); + + it('keeps dependency-manager symlinks outside author-source policy', async () => { + /** 当前测试独占的临时工程。 */ + const fixture = await project(); + /** 模拟 pnpm node_modules package link,但不把它登记成作者 root。 */ + const store = path.join(fixture.root, '.pnpm-store', 'package'); + await fs.mkdir(store, { recursive: true }); + await fs.writeFile(path.join(store, 'index.js'), 'export {};\n'); + await fs.mkdir(path.join(fixture.root, 'node_modules'), { recursive: true }); + await fs.symlink(store, path.join(fixture.root, 'node_modules', 'package')); + /** 当前测试独占 capability scope。 */ + const scope = new BuildSessionScope(); + /** Source Registry 只管理显式作者 root。 */ + const registry = new SourceRegistry(scope, fixture.root); + /** 作者 root 的签发和枚举不受工程其他位置依赖 symlink 影响。 */ + const root = await registry.issueRoot('extension:owned', fixture.sourceRoot); + + await expect(registry.service('extension:owned').list(root)).resolves.toEqual(expect.any(Array)); + }); +}); diff --git a/packages/core/test/services/watch-registry.test.ts b/packages/core/test/services/watch-registry.test.ts new file mode 100644 index 0000000..8a09e2e --- /dev/null +++ b/packages/core/test/services/watch-registry.test.ts @@ -0,0 +1,114 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { BuildSessionScope } from '../../src/services/session-scope.js'; +import { WatchRegistry } from '../../src/services/watch.js'; + +/** Watch Registry 测试创建的临时根。 */ +const roots: string[] = []; + +/** + * 创建工程内文件和工程外 package 文件。 + * + * @returns 当前测试独占的 watch fixture。 + */ +async function fixture() { + /** 当前测试独占工程根。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-watch-registry-')); + roots.push(root); + /** 工程内两个可替换 observation。 */ + const alpha = path.join(root, 'src', 'alpha.ts'); + /** 第二个工程内 observation。 */ + const beta = path.join(root, 'src', 'beta.ts'); + await fs.mkdir(path.dirname(alpha), { recursive: true }); + await fs.writeFile(alpha, 'export const alpha = 1;\n'); + await fs.writeFile(beta, 'export const beta = 2;\n'); + /** 工程真实根外的模拟 package store。 */ + const packageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-watch-package-')); + roots.push(packageRoot); + /** 外部 package entry 必须带安全逻辑 identity。 */ + const packageEntry = path.join(packageRoot, 'index.js'); + await fs.writeFile(packageEntry, 'export {};\n'); + /** 当前 BuildSession scope。 */ + const scope = new BuildSessionScope(); + return { root, alpha, beta, packageRoot, packageEntry, scope, watch: new WatchRegistry(scope, root) }; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('WatchRegistry', () => { + it('atomically replaces and removes owner operations with immutable snapshots', async () => { + /** 当前测试独占 Registry。 */ + const current = await fixture(); + await current.watch.replace('extension:fixture', 'module/config', [ + { path: current.alpha, type: 'file' }, + { path: current.packageEntry, type: 'file', identity: 'package:fixture@1.0.0/index.js' }, + ]); + /** 首次完整 operation 快照。 */ + const initial = current.watch.snapshot(); + + expect(initial.identities).toEqual(['package:fixture@1.0.0/index.js', 'src/alpha.ts']); + expect(Object.isFrozen(initial)).toBe(true); + expect(Object.isFrozen(initial.paths)).toBe(true); + expect(Object.isFrozen(initial.identities)).toBe(true); + expect(initial.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ path: await fs.realpath(current.packageEntry), identity: 'package:fixture@1.0.0/index.js', type: 'file' }), + ])); + expect(Object.isFrozen(initial.observations)).toBe(true); + + /** replace 必须移除旧 observation,而不是增量残留。 */ + await current.watch.replace('extension:fixture', 'module/config', [{ path: current.beta, type: 'file' }]); + expect(current.watch.snapshot().identities).toEqual(['src/beta.ts']); + current.watch.remove('extension:fixture', 'module/config'); + expect(current.watch.snapshot()).toEqual({ paths: [], identities: [], observations: [] }); + }); + + it('allows package-manager directory symlinks but rejects unsafe observations', async () => { + /** 当前测试独占 Registry。 */ + const current = await fixture(); + /** node_modules 目录链接模拟 pnpm package link。 */ + const linkedPackage = path.join(current.root, 'node_modules', 'fixture'); + await fs.mkdir(path.dirname(linkedPackage), { recursive: true }); + await fs.symlink(current.packageRoot, linkedPackage, 'dir'); + await expect(current.watch.replace('framework:compiler', 'compiler/job', [{ + path: path.join(linkedPackage, 'index.js'), + type: 'file', + identity: 'package:fixture@1.0.0/index.js', + }])).resolves.toBeUndefined(); + + /** 最终文件 symlink 不属于 dependency-manager 目录链接例外。 */ + const linkedFile = path.join(current.root, 'linked.ts'); + await fs.symlink(current.alpha, linkedFile); + await expect(current.watch.replace('framework:compiler', 'compiler/link', [{ path: linkedFile, type: 'file' }])).rejects.toThrow('regular file'); + await expect(current.watch.replace('framework:compiler', 'compiler/missing', [{ path: path.join(current.root, 'missing.ts'), type: 'file' }])).rejects.toThrow('regular file'); + await expect(current.watch.replace('framework:compiler', 'compiler/external', [{ path: current.packageEntry, type: 'file' }])).rejects.toThrow('package identity'); + /** Resource Registry 可观察空目录,供后续新增文件触发 Dev rebuild。 */ + await expect(current.watch.replace('framework:resource', 'resource/src', [{ path: path.join(current.root, 'src'), type: 'directory' }])).resolves.toBeUndefined(); + expect(current.watch.snapshot().identities).toContain('src'); + await expect(current.watch.replace('framework:resource', 'resource/wrong-type', [{ path: current.alpha, type: 'directory' }])).rejects.toThrow('regular directory'); + }); + + it('rejects ambiguous identities and expired BuildSessions', async () => { + /** 当前测试独占 Registry。 */ + const current = await fixture(); + /** 第二个外部文件用于 identity 一对多测试。 */ + const other = path.join(current.packageRoot, 'other.js'); + await fs.writeFile(other, 'export {};\n'); + + await expect(current.watch.replace('extension:fixture', 'module/duplicate', [ + { path: current.packageEntry, type: 'file', identity: 'package:fixture@1.0.0/index.js' }, + { path: other, type: 'file', identity: 'package:fixture@1.0.0/index.js' }, + ])).rejects.toThrow('multiple files'); + await expect(current.watch.replace('extension:fixture', 'module/case', [ + { path: current.packageEntry, type: 'file', identity: 'package:fixture@1.0.0/Foo.js' }, + { path: other, type: 'file', identity: 'package:fixture@1.0.0/foo.js' }, + ])).rejects.toThrow('normalization collision'); + + current.scope.close(); + expect(() => current.watch.snapshot()).toThrow('no longer active'); + await expect(current.watch.replace('extension:fixture', 'module/expired', [{ path: current.alpha, type: 'file' }])).rejects.toThrow('no longer active'); + }); +}); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..6ece4d3 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts new file mode 100644 index 0000000..af84412 --- /dev/null +++ b/packages/core/tsdown.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'tsdown'; +import { fileURLToPath } from 'node:url'; + +// 私有 Core 生成 Node ESM 与 OXC 声明,由主包内联并供工作区类型检查复用。 +export default defineConfig({ + entry: { + index: fileURLToPath(new URL('./src/index.ts', import.meta.url)), + author: fileURLToPath(new URL('./src/api/author.ts', import.meta.url)), + integration: fileURLToPath(new URL('./src/api/integration.ts', import.meta.url)), + }, + format: ['esm'], + platform: 'node', + target: 'node20', + dts: { generator: 'oxc' }, + clean: true, + sourcemap: false, +}); diff --git a/packages/docs/.vitepress/config.mts b/packages/docs/.vitepress/config.mts new file mode 100644 index 0000000..d1b8989 --- /dev/null +++ b/packages/docs/.vitepress/config.mts @@ -0,0 +1,130 @@ +import { defineConfig } from 'vitepress'; +import typedocSidebar from '../api/typedoc-sidebar.json'; + +/** ACPlugin 文档站的稳定导航、品牌资源与本地构建配置。 */ +export default defineConfig({ + lang: 'zh-CN', + title: 'ACPlugin', + description: '统一的 AI Plugin 框架与 CLI', + cleanUrls: true, + lastUpdated: false, + head: [ + ['link', { rel: 'icon', type: 'image/svg+xml', href: '/acplugin-mark.svg' }], + ['meta', { name: 'theme-color', content: '#f6c915' }], + ], + markdown: { + image: { lazyLoading: true }, + }, + themeConfig: { + logo: { + light: '/acplugin-logo.svg', + dark: '/acplugin-logo-dark.svg', + alt: 'ACPlugin', + }, + siteTitle: false, + nav: [ + { text: '指南', link: '/guide/' }, + { text: '配置', link: '/config/' }, + { text: '平台', link: '/platforms/' }, + { text: '扩展', link: '/extensions/' }, + { + text: '生态', + items: [ + { text: '🧩 生态开发', link: '/ecosystem/' }, + { text: '🧪 Playground', link: '/playground/' }, + { text: '📚 参考资源', link: '/resources/' }, + ], + }, + { text: 'API', link: '/api/' }, + ], + sidebar: { + '/guide/': [ + { + text: '开始', + items: [ + { text: '指南总览', link: '/guide/' }, + { text: '为什么使用 ACPlugin', link: '/guide/why-acplugin' }, + { text: '快速开始', link: '/guide/getting-started' }, + { text: '工程结构', link: '/guide/project-structure' }, + ], + }, + { + text: '核心工作流', + items: [ + { text: 'Commands、Skills 与 Agents', link: '/guide/commands-skills-agents' }, + { text: '构建与校验', link: '/guide/build-and-validate' }, + { text: '内建 Node Runtime', link: '/guide/node-runtime' }, + { text: 'CLI', link: '/guide/cli' }, + { text: 'Migration', link: '/guide/migration' }, + { text: '故障排查', link: '/guide/troubleshooting' }, + ], + }, + ], + '/config/': [ + { text: '配置总览', link: '/config/' }, + { text: '工程元数据', link: '/config/project-metadata' }, + { text: 'Public 文件', link: '/config/public-files' }, + { text: '构建选项', link: '/config/build-options' }, + { text: '兼容性与 strict', link: '/config/compatibility-and-strictness' }, + ], + '/platforms/': [ + { text: '平台总览', link: '/platforms/' }, + { text: 'Claude Code', link: '/platforms/claude-code' }, + { text: 'Codex', link: '/platforms/codex' }, + { text: 'Cursor', link: '/platforms/cursor' }, + { text: 'Antigravity', link: '/platforms/antigravity' }, + { text: 'OpenCode', link: '/platforms/opencode' }, + { text: 'Pi', link: '/platforms/pi' }, + ], + '/extensions/': [ + { text: '扩展总览', link: '/extensions/' }, + { text: 'Hooks', link: '/extensions/hooks' }, + { text: 'MCP', link: '/extensions/mcp' }, + ], + '/ecosystem/': [ + { text: '生态开发总览', link: '/ecosystem/' }, + { text: 'Platform 开发', link: '/ecosystem/platform-authoring' }, + { text: 'Extension 开发', link: '/ecosystem/extension-authoring' }, + { text: 'Rolldown Build Service', link: '/ecosystem/build-service' }, + { text: 'Lifecycle 契约', link: '/ecosystem/lifecycle-contract' }, + { text: 'Asset、Document 与 Package', link: '/ecosystem/assets-and-documents' }, + { text: 'Package 与 peer 边界', link: '/ecosystem/package-and-peer-boundaries' }, + ], + '/playground/': [ + { text: 'Playground 总览', link: '/playground/' }, + { text: '全能力模板', link: '/playground/capability-template' }, + ], + '/resources/': [ + { text: '资源总览', link: '/resources/' }, + { text: '兼容性矩阵', link: '/resources/compatibility-matrix' }, + { text: '确定性构建', link: '/resources/deterministic-builds' }, + { text: '安全模型', link: '/resources/security-model' }, + { text: 'Package map', link: '/resources/package-map' }, + ], + '/api/': typedocSidebar, + }, + search: { + provider: 'local', + options: { + miniSearch: { + searchOptions: { fuzzy: 0.2, prefix: true }, + }, + }, + }, + socialLinks: [ + { icon: 'github', link: 'https://github.com/TokenRollAI/acplugin' }, + ], + outline: { level: [2, 3], label: '本页内容' }, + docFooter: { prev: '上一页', next: '下一页' }, + darkModeSwitchLabel: '外观', + lightModeSwitchTitle: '切换到浅色主题', + darkModeSwitchTitle: '切换到深色主题', + sidebarMenuLabel: '目录', + returnToTopLabel: '返回顶部', + externalLinkIcon: true, + footer: { + message: '为可移植的 AI 工作流而构建 · MIT License', + copyright: 'ACPlugin by TokenRoll', + }, + }, +}); diff --git a/packages/docs/.vitepress/theme/custom.css b/packages/docs/.vitepress/theme/custom.css new file mode 100644 index 0000000..5fb8c3d --- /dev/null +++ b/packages/docs/.vitepress/theme/custom.css @@ -0,0 +1,444 @@ +:root { + --acp-banana-50: #fffdf2; + --acp-banana-100: #fff8ce; + --acp-banana-200: #ffed8a; + --acp-banana-300: #ffdd45; + --acp-banana-400: #f6c915; + --acp-banana-500: #dca900; + --acp-ink: #20221b; + --acp-ink-muted: #5d604f; + --acp-border: rgb(138 101 0 / 18%); + --acp-shadow: 0 18px 50px rgb(112 81 0 / 10%); + --acp-code-border: rgb(111 81 0 / 18%); + --acp-code-label: #765900; + + --vp-c-brand-1: #856000; + --vp-c-brand-2: #6f4f00; + --vp-c-brand-3: #553c00; + --vp-c-brand-soft: rgb(246 201 21 / 17%); + --vp-c-bg: #fffdf7; + --vp-c-bg-alt: #fff9e8; + --vp-c-bg-elv: #fffefb; + --vp-c-bg-soft: #fff8df; + --vp-c-divider: rgb(88 68 7 / 14%); + --vp-c-gutter: rgb(88 68 7 / 10%); + --vp-c-text-1: var(--acp-ink); + --vp-c-text-2: var(--acp-ink-muted); + --vp-code-block-color: #46483e; + --vp-code-block-bg: #f8f4e7; + --vp-code-block-divider-color: rgb(111 81 0 / 14%); + --vp-code-line-highlight-color: rgb(246 201 21 / 12%); + --vp-code-copy-code-bg: #fffdf7; + --vp-code-copy-code-hover-bg: #fff8df; + --vp-code-tab-divider: var(--vp-code-block-divider-color); + --vp-home-hero-name-color: transparent; + --vp-home-hero-name-background: linear-gradient(110deg, #795500 0%, #c28c00 38%, #f6c915 66%, #9b6b00 100%); + --vp-home-hero-image-background-image: radial-gradient(circle, rgb(255 221 69 / 50%) 0%, rgb(246 201 21 / 16%) 48%, transparent 72%); + --vp-home-hero-image-filter: blur(4px); +} + +.dark { + --acp-ink: #fffbed; + --acp-ink-muted: #c9c6b7; + --acp-border: rgb(255 221 69 / 18%); + --acp-shadow: 0 18px 60px rgb(0 0 0 / 28%); + --acp-code-border: rgb(255 221 69 / 16%); + --acp-code-label: #d9c774; + + --vp-c-brand-1: #ffdd45; + --vp-c-brand-2: #f6c915; + --vp-c-brand-3: #d6a817; + --vp-c-brand-soft: rgb(255 221 69 / 14%); + --vp-c-bg: #171812; + --vp-c-bg-alt: #11120e; + --vp-c-bg-elv: #202119; + --vp-c-bg-soft: #24251c; + --vp-c-divider: rgb(255 240 177 / 12%); + --vp-c-gutter: rgb(255 240 177 / 8%); + --vp-c-text-1: var(--acp-ink); + --vp-c-text-2: var(--acp-ink-muted); + --vp-code-block-color: #d7dacb; + --vp-code-block-bg: #1d1f19; + --vp-code-block-divider-color: rgb(255 221 69 / 12%); + --vp-code-line-highlight-color: rgb(255 221 69 / 10%); + --vp-code-copy-code-bg: #292b23; + --vp-code-copy-code-hover-bg: #32342b; + --vp-home-hero-name-background: linear-gradient(110deg, #fff3ad 0%, #ffdd45 45%, #e3b20b 100%); + --vp-home-hero-image-background-image: radial-gradient(circle, rgb(255 221 69 / 32%) 0%, rgb(246 201 21 / 12%) 52%, transparent 72%); +} + +body { + background-image: + radial-gradient(circle at 8% 4%, rgb(255 221 69 / 10%), transparent 24rem), + radial-gradient(circle at 94% 32%, rgb(246 201 21 / 7%), transparent 28rem); + background-attachment: fixed; +} + +::selection { + color: #171812; + background: var(--acp-banana-300); +} + +.VPNav { + border-bottom: 1px solid var(--vp-c-divider); + background: color-mix(in srgb, var(--vp-c-bg) 82%, transparent); + backdrop-filter: blur(18px) saturate(150%); +} + +.VPNavBarTitle .logo { + width: 120px; + height: auto; + max-height: 34px; +} + +.VPNavBarMenuLink, +.VPNavBarMenuGroup .button { + font-weight: 650; + letter-spacing: 0.01em; +} + +.VPNavBarMenuLink.active, +.VPNavBarMenuLink:hover, +.VPNavBarMenuGroup .button:hover { + color: var(--vp-c-brand-1); +} + +.VPNavBarSearch .DocSearch-Button, +.VPNavBarSearch .VPNavBarSearchButton { + border: 1px solid var(--acp-border); + border-radius: 999px; + background: color-mix(in srgb, var(--vp-c-bg-soft) 78%, transparent); +} + +.VPHomeHero { + padding-top: 112px !important; +} + +.VPHomeHero .container { + gap: 48px; +} + +.VPHomeHero .name { + max-width: 760px; + letter-spacing: -0.055em; +} + +.VPHomeHero .text { + max-width: 760px; + letter-spacing: -0.035em; +} + +.VPHomeHero .tagline { + max-width: 650px; + font-size: 21px; + line-height: 1.7; +} + +.VPHomeHero .image-bg { + width: 360px; + height: 360px; +} + +.VPHomeHero .image-src { + width: 260px; + height: 260px; + filter: drop-shadow(0 24px 32px rgb(103 75 0 / 22%)); + animation: acp-float 7s ease-in-out infinite; +} + +.VPButton { + border-radius: 999px !important; + font-weight: 750 !important; + letter-spacing: 0.01em; + transition: transform 180ms ease, box-shadow 180ms ease, background-color 180ms ease !important; +} + +.VPButton.brand { + border-color: #e0af05 !important; + color: #20221b !important; + background: linear-gradient(135deg, var(--acp-banana-300), var(--acp-banana-400)) !important; + box-shadow: 0 10px 26px rgb(184 133 0 / 24%); +} + +.VPButton.brand:hover { + border-color: #c99700 !important; + background: linear-gradient(135deg, #ffe66f, var(--acp-banana-300)) !important; + box-shadow: 0 14px 32px rgb(184 133 0 / 30%); + transform: translateY(-2px); +} + +.VPButton.alt { + border-color: var(--acp-border) !important; + background: color-mix(in srgb, var(--vp-c-bg-elv) 84%, transparent) !important; + box-shadow: 0 8px 22px rgb(78 61 7 / 8%); +} + +.VPButton.alt:hover { + border-color: var(--vp-c-brand-1) !important; + transform: translateY(-2px); +} + +.VPFeatures { + padding-bottom: 72px !important; +} + +.VPFeature { + position: relative; + overflow: hidden; + border: 1px solid var(--acp-border) !important; + border-radius: 20px !important; + background: color-mix(in srgb, var(--vp-c-bg-elv) 92%, transparent) !important; + box-shadow: 0 10px 34px rgb(83 63 0 / 6%); + transition: border-color 200ms ease, box-shadow 200ms ease, transform 200ms ease; +} + +.VPFeature::before { + position: absolute; + top: 0; + right: 0; + left: 0; + height: 3px; + background: linear-gradient(90deg, var(--acp-banana-300), var(--acp-banana-500)); + content: ''; + opacity: 0; + transition: opacity 200ms ease; +} + +.VPFeature:hover { + border-color: rgb(218 166 0 / 42%) !important; + box-shadow: var(--acp-shadow); + transform: translateY(-5px); +} + +.VPFeature:hover::before { + opacity: 1; +} + +.VPFeature .icon { + border: 1px solid rgb(218 166 0 / 18%); + background: var(--vp-c-brand-soft) !important; + box-shadow: inset 0 1px 0 rgb(255 255 255 / 34%); +} + +.VPFeature .title { + font-size: 17px; + letter-spacing: -0.015em; +} + +.VPDoc .content-container { + max-width: 760px !important; +} + +.vp-doc h1 { + margin-bottom: 24px; + font-size: clamp(34px, 5vw, 48px); + line-height: 1.12; + letter-spacing: -0.045em; +} + +.vp-doc h2 { + border-top-color: var(--acp-border); + letter-spacing: -0.025em; +} + +.vp-doc h2::after { + display: block; + width: 42px; + height: 4px; + margin-top: 10px; + border-radius: 999px; + background: linear-gradient(90deg, var(--acp-banana-400), var(--acp-banana-300)); + content: ''; +} + +.vp-doc h3 { + letter-spacing: -0.015em; +} + +.vp-doc a { + text-decoration-color: rgb(218 166 0 / 35%); + text-decoration-thickness: 2px; + text-underline-offset: 3px; +} + +.vp-doc :not(pre) > code { + border: 1px solid var(--acp-border); + border-radius: 7px; + color: var(--vp-c-brand-1); + background: var(--vp-c-brand-soft); +} + +.vp-doc div[class*='language-'] { + border: 1px solid var(--acp-code-border); + border-radius: 16px; + box-shadow: 0 12px 30px rgb(53 45 18 / 9%); +} + +.vp-doc div[class*='language-'] > span.lang { + color: var(--acp-code-label); +} + +.vp-doc blockquote { + border-left: 4px solid var(--acp-banana-400); + border-radius: 0 12px 12px 0; + background: var(--vp-c-brand-soft); +} + +.vp-doc table { + display: table; + width: 100%; + max-width: 100%; + border-collapse: separate; + border-spacing: 0; + border: 1px solid var(--acp-border); + border-radius: 14px; + overflow: hidden; +} + +.vp-doc th, +.vp-doc td { + overflow-wrap: anywhere; +} + +.vp-doc th { + background: var(--vp-c-brand-soft); +} + +.vp-doc tr:last-child td:first-child { + border-bottom-left-radius: 13px; +} + +.vp-doc tr:last-child td:last-child { + border-bottom-right-radius: 13px; +} + +.vp-doc .custom-block { + border-radius: 14px; + box-shadow: inset 4px 0 0 rgb(246 201 21 / 72%); +} + +.VPSidebarItem.is-active > .item .link > .text { + color: var(--vp-c-brand-1); + font-weight: 750; +} + +.VPDocAsideOutline .outline-marker { + background-color: var(--acp-banana-400); +} + +.acp-home-intro { + margin: 24px auto 56px; + text-align: center; +} + +.acp-kicker { + display: inline-flex; + align-items: center; + gap: 8px; + margin: 0 0 12px; + padding: 7px 12px; + border: 1px solid var(--acp-border); + border-radius: 999px; + color: var(--vp-c-brand-1); + background: var(--vp-c-brand-soft); + font-size: 13px; + font-weight: 750; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.acp-flow { + display: grid; + grid-template-columns: repeat(7, auto); + align-items: center; + justify-content: center; + gap: 12px; + margin: 32px 0 52px; +} + +.acp-flow__step { + min-width: 128px; + padding: 18px 16px; + border: 1px solid var(--acp-border); + border-radius: 16px; + background: color-mix(in srgb, var(--vp-c-bg-elv) 92%, transparent); + box-shadow: 0 10px 28px rgb(83 63 0 / 7%); + text-align: center; +} + +.acp-flow__icon { + display: block; + margin-bottom: 8px; + font-size: 24px; +} + +.acp-flow__label { + color: var(--vp-c-text-1); + font-size: 14px; + font-weight: 750; +} + +.acp-flow__arrow { + color: var(--acp-banana-500); + font-size: 22px; + font-weight: 800; +} + +@keyframes acp-float { + 0%, + 100% { + transform: translateY(0) rotate(-2deg); + } + + 50% { + transform: translateY(-12px) rotate(2deg); + } +} + +@media (max-width: 768px) { + .VPHomeHero { + padding-top: 72px !important; + } + + .VPHomeHero .tagline { + font-size: 18px; + } + + .VPHomeHero .image-src { + width: 210px; + height: 210px; + } + + .vp-doc table { + table-layout: fixed; + } + + .vp-doc th, + .vp-doc td { + padding: 8px 10px; + } + + .vp-doc td code { + white-space: normal; + overflow-wrap: anywhere; + } + + .acp-flow { + grid-template-columns: 1fr; + } + + .acp-flow__arrow { + transform: rotate(90deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .VPHomeHero .image-src { + animation: none; + } + + .VPButton, + .VPFeature { + transition: none !important; + } +} diff --git a/packages/docs/.vitepress/theme/index.ts b/packages/docs/.vitepress/theme/index.ts new file mode 100644 index 0000000..14c9676 --- /dev/null +++ b/packages/docs/.vitepress/theme/index.ts @@ -0,0 +1,5 @@ +import DefaultTheme from 'vitepress/theme'; +import './custom.css'; + +/** 基于 VitePress 默认主题的轻量 ACPlugin 主题。 */ +export default DefaultTheme; diff --git a/packages/docs/config/build-options.md b/packages/docs/config/build-options.md new file mode 100644 index 0000000..d9393ca --- /dev/null +++ b/packages/docs/config/build-options.md @@ -0,0 +1,37 @@ +# 构建选项 + +```ts +export default defineConfig({ + // ...metadata and platforms + srcDir: 'src', + build: { + outDir: 'dist', + strict: true, + }, +}); +``` + +| 字段 | 默认值 | 说明 | +| --- | --- | --- | +| `srcDir` | `src` | Canonical 与 Extension 作者源码根 | +| `runtime` | `{}` | 内建 Runtime 自动发现、显式入口与 portable-node 编译参数;`false` 关闭 | +| `build.outDir` | `dist` | 框架完整托管的输出根 | +| `build.strict` | `true` | 是否拒绝 degraded/unsupported 兼容性 | + +路径都相对于配置文件所在工程根解析,必须留在根目录内。`srcDir`、Public 和 `outDir` 不能相互包含,`outDir` 也不能等于工程根。 + +`runtime.entries` 的入口路径相对于 `/runtime`;配置存在时会完整替换一级文件自动发现。详细边界见[内建 Node Runtime](/guide/node-runtime)。 + +## 函数式配置 + +```ts +export default defineConfig(({ command, mode }) => ({ + name: 'my-plugin', + version: '1.0.0', + description: `${command} configuration`, + platforms: [claudeCode()], + build: { strict: mode === 'production' }, +})); +``` + +环境只包含 `command` 和 `mode`。`dev` 默认 development,其余项目命令默认 production;CLI `--mode` 可覆盖。配置加载不会自动读取 `.env`。 diff --git a/packages/docs/config/compatibility-and-strictness.md b/packages/docs/config/compatibility-and-strictness.md new file mode 100644 index 0000000..e6504ae --- /dev/null +++ b/packages/docs/config/compatibility-and-strictness.md @@ -0,0 +1,35 @@ +# 兼容性与 strict + +兼容性是逐 Platform、逐资源、逐字段报告的显式结果: + +| Level | 含义 | +| --- | --- | +| `native` | 目标平台直接表达同一语义 | +| `transform` | 经过受控转换后保持语义 | +| `degraded` | 产物可用,但部分语义或控制能力丢失 | +| `unsupported` | 目标平台不能提供经过验证的实现 | + +## 严格度层级 + +全局默认 `build.strict: true`。Platform factory 可以单独覆盖: + +```ts +export default defineConfig({ + // ...metadata + platforms: [ + claudeCode(), + codex({ strict: false }), + ], +}); +``` + +Platform override 优先于 `build.strict` 默认。项目 CLI 不提供临时 strict override,保证本地、CI 和程序化调用使用同一份显式策略。 + +Relaxed 模式只允许功能兼容性继续构建,并保留完整报告。以下错误始终失败: + +- 配置、Frontmatter、依赖图或 Schema 无效; +- Asset 来源越权、路径冲突或 owner 冲突; +- Extension/Contributor API 不兼容; +- Package 候选校验或 transaction 失败。 + +建议默认 strict,只在明确接受一个已审查的降级时对特定 Platform 放宽,并在 CI 检查报告中允许的诊断集合。 diff --git a/packages/docs/config/index.md b/packages/docs/config/index.md new file mode 100644 index 0000000..5aa6f9d --- /dev/null +++ b/packages/docs/config/index.md @@ -0,0 +1,23 @@ +# 配置参考 + +`acplugin.config.ts` 负责工程元数据、显式 Platform/Extension 实例和构建策略。通用配置与各平台专属 options 分开说明。 + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [claudeCode()], +}); +``` + +`platforms` 是必填数组,没有运行时默认。配置也可以导出同步或异步函数,接收稳定的 `{ command, mode }`;不要在配置中读取未声明的机器环境来改变生产产物。 + +- [工程元数据](./project-metadata.md) +- [Public 文件](./public-files.md) +- [内建 Node Runtime](/guide/node-runtime) +- [构建选项](./build-options.md) +- [兼容性与 strict](./compatibility-and-strictness.md) diff --git a/packages/docs/config/project-metadata.md b/packages/docs/config/project-metadata.md new file mode 100644 index 0000000..eb915da --- /dev/null +++ b/packages/docs/config/project-metadata.md @@ -0,0 +1,36 @@ +# 工程元数据 + +| 字段 | 必填 | 约束 | +| --- | --- | --- | +| `name` | 是 | 小写 kebab-case | +| `version` | 是 | 完整合法 SemVer | +| `description` | 是 | 非空字符串 | +| `displayName` | 否 | 非空展示名 | +| `author` | 否 | `{ name, email?, url? }` | +| `homepage` | 否 | 绝对 HTTP(S) URL | +| `repository` | 否 | 绝对 HTTP(S) URL | +| `license` | 否 | 合法 SPDX expression | +| `keywords` | 否 | 非空、去空白后不重复的字符串数组 | + +```ts +export default defineConfig({ + name: 'review-tools', + version: '1.2.0', + description: 'Shared repository review workflows.', + displayName: 'Review Tools', + author: { + name: 'Example Team', + email: 'maintainers@example.com', + url: 'https://example.com/team', + }, + homepage: 'https://example.com/review-tools', + repository: 'https://github.com/example/review-tools', + license: 'MIT', + keywords: ['review', 'workflow'], + platforms: [claudeCode()], +}); +``` + +Platform 会逐字段报告元数据是 native、transform、degraded、unsupported 或 omitted。一个目标 Schema 不支持某字段时不会偷偷写入未知字段。 + +完整类型见 [`UserConfig`](/api/@tokenroll/acplugin/interfaces/UserConfig.md) 与 [`PluginMetadata`](/api/@tokenroll/acplugin/interfaces/PluginMetadata.md)。 diff --git a/packages/docs/config/public-files.md b/packages/docs/config/public-files.md new file mode 100644 index 0000000..f7e448c --- /dev/null +++ b/packages/docs/config/public-files.md @@ -0,0 +1,38 @@ +# Public 文件 + +默认情况下,Core 扫描工程根的 `public/`,把其中普通文件签发为 Framework-owned Asset,并向每个平台的 base Package 提交 add-only Contribution。可执行位规范为 `0755`,其他普通文件为 `0644`。 + +## 关闭或改目录 + +```ts +export default defineConfig({ + // ...metadata and platforms + public: false, +}); +``` + +```ts +export default defineConfig({ + // ...metadata and platforms + public: 'static', +}); +``` + +## Copy rules + +```ts +export default defineConfig({ + // ...metadata and platforms + public: { + dir: 'assets', + copy: [ + { from: 'templates', to: 'resources/templates' }, + { from: 'LICENSE', to: 'LICENSE' }, + ], + }, +}); +``` + +`from` 与 `to` 都必须是安全相对路径,不能含绝对路径、NUL 或 `..` 片段。来源必须是 Scanner 精确发现的普通文件;符号链接和特殊文件会失败。 + +Public 路径仍受全局大小写、Unicode normalization、文件/目录和 owner 冲突检查。它不能覆盖 Platform 或 Extension 已拥有的 Asset 路径。 diff --git a/packages/docs/ecosystem/assets-and-documents.md b/packages/docs/ecosystem/assets-and-documents.md new file mode 100644 index 0000000..4ef2915 --- /dev/null +++ b/packages/docs/ecosystem/assets-and-documents.md @@ -0,0 +1,24 @@ +# Asset、Document 与 Package + +## Asset + +Asset 是 Core Registry 签发的不透明引用,来源为已验证源码、Compiler 输出或复制后的内存字节。mode 只允许 `0644`/`0755`。Core 绑定真实 issuer owner、结构化 origin、size 与 SHA-256;Package 只保存路径映射和 AssetRef。 + +路径会拒绝: + +- POSIX/Win32 绝对路径、NUL 与任何 `..` 片段; +- 符号链接和特殊文件; +- 大小写、Unicode normalization、文件/目录前缀冲突; +- Platform/Extension/Framework 之间未经 grant 的跨 owner 引用。 + +## Document 与 Contribution + +Platform `createPackage()` 创建结构化 JSON/YAML/TOML/frontmatter Document,并声明仍为空的 extension point。所有 Contributor 读取同一份不可变 base snapshot,只能提交字段路径和值;Core 统一验证并编码 Document。 + +add-only merge 不提供 replace、remove、数组 append 或深度覆盖。两个 owner 写同一字段或同一 Asset 路径会失败,不存在 first/last-writer-wins。 + +## Package 与 transaction + +Platform `finalizePackage()` 为集中合并后的 snapshot 决定主 Package 身份并可追加自己的 Asset。Core 自动保留所有继承 Asset 的 owner、mode、size、hash 和 origin。主 Package 物化并通过 `validatePackage()` 后,Platform 才能派生可选 Distribution;Distribution 也要单独物化和校验。 + +所有所选 Package Unit 都通过后,Core 才按锁 → 恢复 → stage → 校验 → transaction/backup → swap → cleanup 提交托管 `dist`。任何失败保留上一份完整输出。 diff --git a/packages/docs/ecosystem/build-service.md b/packages/docs/ecosystem/build-service.md new file mode 100644 index 0000000..35de724 --- /dev/null +++ b/packages/docs/ecosystem/build-service.md @@ -0,0 +1,27 @@ +# 统一 Rolldown Compiler + +Core 是一次 BuildSession 中唯一的 Rolldown owner。Platform、Extension 与第三方集成都通过 `context.compiler.compile()` 提交声明式 Job,不能创建自己的 bundler、watcher 或输出目录。 + +## `portable-node` + +面向需要跨平台交付的 Node 20 ESM 可执行内容,也是内建 Runtime、Hooks 与本地 MCP 使用的 profile: + +- 输出自包含 ESM bundle,只有 `node:` 内置模块保持 external; +- 静态/动态 import 必须可解析,拒绝原生扩展和隐式运行时依赖; +- 固定无 sourcemap,禁止输出绝对路径、时间戳和环境值; +- 许可证默认严格收集,无法确认第三方许可时失败; +- 模块图、descriptor、tsconfig、package 与 license 文件进入 Core Watch Registry。 + +作者可通过 `PortableNodeCompileOptions` 调整受限的 `resolve`、`transform` 和 `treeshake` JSON 字段,但不能注入 Rolldown Plugin 或回调。 + +## `managed-rolldown` + +面向第三方 Platform/Extension 的高级构建。它暴露 Rolldown 能力的受管子集,包括 plugin,但 Core 始终接管: + +- `cwd`、input 身份与 source scope; +- 输出目录、日志、watch 登记和关闭; +- 禁止 `writeBundle`、`watchChange`、`closeWatcher` 等越权 Hook; +- workDir、Asset 签发、模块图审计和确定性边界; +- 默认 `strict` 的第三方许可证策略。 + +显式 `licenses: 'ignore'` 表示调用方自行承担法律材料交付责任;它不会关闭路径、模块图或 owner 安全检查。Compile 输出只能作为 `GeneratedAssetRef` 进入 Package,不能在构建后复制、重命名或 patch `dist`。 diff --git a/packages/docs/ecosystem/extension-authoring.md b/packages/docs/ecosystem/extension-authoring.md new file mode 100644 index 0000000..2f34491 --- /dev/null +++ b/packages/docs/ecosystem/extension-authoring.md @@ -0,0 +1,89 @@ +# Extension 开发 + +Extension 表达横向作者能力:发现并验证自己的资源,使用 Core Host 只构建一次不可变 Built State,再通过明确的 `PlatformContributor` add-only 参与目标 Package。 + +```ts +import { defineExtension } from '@tokenroll/acplugin/sdk'; + +export function notices() { + return defineExtension({ + id: 'example-notices', + apiVersion: '1', + options: {}, + resourceRoots: ['notices'], + createSession() { + return { + discover: async () => ({ enabled: true }), + validate: async (_context, discovered) => ({ + state: discovered, + subjects: [{ subject: 'notices:default', capabilities: ['delivery'] }], + }), + build: async (_context, validated) => ({ state: validated }), + contributors: [{ + platform: 'example', + platformApiVersion: '1', + async contribute(context, built) { + const notice = await context.assets.fromBytes({ + bytes: built.enabled ? 'Enabled\n' : 'Disabled\n', + origin: { operation: 'render-notice', subjects: ['notices:default'] }, + }); + return { + assets: [{ path: 'NOTICE.txt', asset: notice }], + compatibility: [{ + subject: 'notices:default', + capability: 'delivery', + level: 'native', + reason: 'The target installs the notice as a native file.', + }], + }; + }, + }], + }; + }, + }); +} +``` + +每个 Contributor 都读取同一个冻结的 Platform base Package。它可以: + +- 读取已公开的 Document 与 extension point; +- 填写一个仍为空且已声明的字段; +- 追加由当前 Extension owner 创建或获授权的 Asset; +- 精确覆盖 `validate()` 声明的兼容性 tuple。 + +Contributor 不能观察其他 Contribution 或 Extension state,不能替换/删除 Platform 内容,也不能 claim/suppress Canonical Component。Core 并发收集贡献,按 owner 稳定排序并集中合并;同字段或同路径竞争稳定失败,不使用配置顺序解决冲突。 + +## Platform Component Contribution + +当 Extension 的私有资源需要成为某个目标的原生资源时,使用该 Platform package 公开的 payload 类型,而不是把私有内容伪装成 Canonical Component、写入 Platform 路径或 patch Manifest。Core 只复制和排序 JSON payload,并把它与当前 Extension 的已声明 subject 绑定;Platform 在 `finalizePackage()` 中独立验证、渲染、处理命名冲突并决定 Manifest 字段。 + +```ts +import type { PlatformContributor } from '@tokenroll/acplugin/sdk'; +import type { ClaudePackageComponent } from '@tokenroll/acplugin-platform-claude-code'; + +const contributor: PlatformContributor>, ClaudePackageComponent> = { + platform: 'claude-code', + platformApiVersion: '1', + contribute: () => ({ + components: [{ + subject: 'example:private-resource', + value: { + kind: 'native-agent', + id: 'observer', + description: 'Observe the project.', + body: 'Observe and report concise findings.', + }, + }], + compatibility: [{ + subject: 'example:private-resource', + capability: 'delivery', + level: 'native', + reason: 'Claude Code installs this private resource as a native Agent.', + }], + }), +}; +``` + +这不是 Slot、Component registry、Extension 排序或 override 协议。payload 的字段、输出路径、名称冲突和支持范围完全由目标 Platform 定义。Claude Code、Cursor 与 OpenCode 首期支持各自的原生 Agent payload;Codex、Antigravity 与 Pi 对非空贡献稳定失败,绝不静默忽略或生成伪 fallback。由 contribution 决定的 Asset 和 Manifest 字段会在 schema-v3 `BuildReport` 中记录可信的 Extension owner/subject provenance。 + +`discover` 使用 `context.sources`/`context.modules`;`build` 使用 `context.compiler`、`context.assets` 与 `context.execution`。Extension 不获得物理 workDir 写权限,不得直接依赖 Rolldown、建立 watcher、写中间文件或写入 `dist`。编译边界见 [统一 Rolldown Compiler](./build-service.md)。 diff --git a/packages/docs/ecosystem/index.md b/packages/docs/ecosystem/index.md new file mode 100644 index 0000000..11b7139 --- /dev/null +++ b/packages/docs/ecosystem/index.md @@ -0,0 +1,12 @@ +# 生态开发 + +第三方作者使用 `@tokenroll/acplugin/sdk` 创建 Platform 或 Extension,不依赖私有 Core。 + +- [Platform 开发](./platform-authoring.md) +- [Extension 开发](./extension-authoring.md) +- [统一 Rolldown Compiler](./build-service.md) +- [Lifecycle 契约](./lifecycle-contract.md) +- [Asset、Document 与 Package](./assets-and-documents.md) +- [Package 与 peer 边界](./package-and-peer-boundaries.md) + +ACPlugin 没有中央 registry、包名强制或自动 npm discovery。用户显式 import 并实例化品牌化对象,Core 只检查公开 API version 与 runtime brand。 diff --git a/packages/docs/ecosystem/lifecycle-contract.md b/packages/docs/ecosystem/lifecycle-contract.md new file mode 100644 index 0000000..f34f07b --- /dev/null +++ b/packages/docs/ecosystem/lifecycle-contract.md @@ -0,0 +1,36 @@ +# Lifecycle 契约 + +Core 是唯一阶段调度者,CLI、`runProject()`、`Project.run()` 与 `Project.dev()` 都只调用这条 Kernel v2 路径: + +```text +config load/resolve +→ Platform/Extension Session setup +→ canonical/Public/Runtime/Extension discovery +→ CanonicalProject assembly +→ Component/Extension validation +→ Extension/Core Runtime compilation +→ Platform.createPackage +→ Framework/Extension Contributor collection +→ Core add-only merge +→ Platform.finalizePackage +→ primary candidate materialize/validate +→ Distribution create/validate +→ compatibility/metadata finalization +→ aggregate materialization validation +→ managed transaction +→ reverse Session close +``` + +## 隔离与顺序 + +- Platform/Extension 按配置顺序 setup;已经初始化的 Session 始终按逆序恰好关闭一次。 +- 各 Extension 的 discover/validate/build state 被复制、冻结并按 owner 隔离,不能跨 Extension 读取。 +- 所有 Contributor 读取同一份 Platform base Package,可以并发执行;Contribution 集中合并且不以配置顺序决定结果。 +- 一个 Platform 的 Package pipeline 失败不抑制其他独立 Platform;工程级错误才阻止全部 Package 消费。 +- `close()` 只收到成功/失败/中止、`committed` 和首个脱敏失败摘要。 + +## Context 与 capability + +Context 只公开当前阶段需要的只读数据和 owner-scoped capability。SourceRef、AssetRef、workDir、Package candidate 都依赖当前 Session 对象身份,不能伪造、跨 owner 使用或保存到后续 Session。 + +生命周期 API version 保持 `1`。版本不匹配在 setup/Contributor 规划阶段失败,不提供 shape fallback 或旧 API 兼容层。 diff --git a/packages/docs/ecosystem/package-and-peer-boundaries.md b/packages/docs/ecosystem/package-and-peer-boundaries.md new file mode 100644 index 0000000..ea4fa80 --- /dev/null +++ b/packages/docs/ecosystem/package-and-peer-boundaries.md @@ -0,0 +1,36 @@ +# Package 与 peer 边界 + +第三方 Platform/Extension 应把 `@tokenroll/acplugin` 声明为 peer dependency,并在开发时同时放入 dev dependency: + +```json +{ + "name": "example-acplugin-platform", + "version": "1.0.0", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, + "peerDependencies": { + "@tokenroll/acplugin": "^0.0.2-beta" + }, + "devDependencies": { + "@tokenroll/acplugin": "^0.0.2-beta" + } +} +``` + +运行时代码不得 import `@acplugin/core`,也不应依赖另一个集成的私有实现。包名、scope 和版本可以独立选择;兼容性由 `apiVersion` 与主包 peer range 表达。 + +Integration factory result 的 `Symbol.for(...)` 只是跨主包 root、SDK 与 CLI bundle chunk 共享的 registry brand。它用于身份互操作,不是 private/security Symbol,也不把第三方代码变成沙箱。Platform/Extension 是可信构建时代码;Core Services 约束可进入 Package 的来源和输出,但不会阻止 Integration 自行调用 Node.js API。 + +发布前至少验证: + +1. ESM-only exports 和声明文件可由 clean consumer 加载。 +2. tarball manifest 不包含 `workspace:`、`@acplugin/*` 或仓库相对路径。 +3. Platform/Extension bundle externalize 主包 peer。 +4. 从 tarball 安装后,品牌化 factory result 能被真实 CLI 接受并完成 build。 + +官方九个包遵守同一模型,不拥有第三方无法使用的生命周期旁路;统一 Build Service 本身就是公开 SDK 能力。 diff --git a/packages/docs/ecosystem/platform-authoring.md b/packages/docs/ecosystem/platform-authoring.md new file mode 100644 index 0000000..8d6e740 --- /dev/null +++ b/packages/docs/ecosystem/platform-authoring.md @@ -0,0 +1,68 @@ +# Platform 开发 + +Platform 把 `CanonicalProject` 转换为一种目标平台 Package。第三方实现只从专用 SDK subpath 导入契约: + +```ts +import { + definePlatform, + type CompatibilityInput, +} from '@tokenroll/acplugin/sdk'; + +export function examplePlatform() { + return definePlatform({ + id: 'example', + apiVersion: '1', + deliveryType: 'plugin', + options: {}, + createSession() { + return { + validateComponent: async () => {}, + async createPackage({ project, assets }) { + const commandAssets = await Promise.all(project.commands.map(async command => ({ + path: `commands/${command.id}.md`, + asset: await assets.fromBytes({ + bytes: command.body, + origin: { operation: 'compile-command', subjects: [`command:${command.id}`] }, + }), + }))); + const compatibility: CompatibilityInput[] = project.commands.map(command => ({ + subject: `command:${command.id}`, + capability: 'delivery', + level: 'native', + reason: 'The target has a native command resource.', + })); + return { + documents: [{ + id: 'manifest', + path: 'plugin.json', + format: 'json', + value: { name: project.metadata.name }, + extensionPoints: [], + }], + assets: commandAssets, + compatibility, + metadata: [], + }; + }, + finalizePackage: () => ({ id: 'plugin', type: 'plugin' }), + validatePackage: async ({ candidate }) => { + // Validate the complete temporary candidate against the target schema. + void candidate.root; + }, + }; + }, + }); +} +``` + +真实实现还必须: + +- 校验 `component.platforms[platformId]` 中的平台专属字段; +- 为每个 Canonical Component、Runtime 和 Extension capability 提交完整兼容性结论; +- 在 `createPackage()` 中建立 base Document/Asset,在 `finalizePackage()` 中只决定主 Package 身份并追加必要的平台 Asset; +- 对 Core 临时物化的完整候选执行 Schema、引用闭包和真实格式校验; +- 只从已经验证的主 Package 派生可选 Marketplace Distribution。 + +Platform 只能使用 Core 签发的 Source/Asset/Compiler capability;物理 workDir 仅由 Core 内部管理。Platform 不能自建 Rolldown、watcher 或写入 `dist`。`PackageCandidate.root` 只在 `validatePackage()` 调用窗口有效,不得保存。 + +不要 import `@acplugin/core`,不要依赖官方 Platform/Extension 私有实现,也不要在 Core 中申请平台 ID 分支。`@tokenroll/acplugin` 根入口面向普通作者;Integration 实现必须使用 `@tokenroll/acplugin/sdk`。 diff --git a/packages/docs/extensions/hooks.md b/packages/docs/extensions/hooks.md new file mode 100644 index 0000000..c45b1b2 --- /dev/null +++ b/packages/docs/extensions/hooks.md @@ -0,0 +1,53 @@ +# Hooks Extension + +## 安装与启用 + +```bash +pnpm add -D @tokenroll/acplugin-extension-hooks +``` + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import hooks from '@tokenroll/acplugin-extension-hooks'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [claudeCode()], + extensions: [hooks()], +}); +``` + +`hooks({ include: ['policy'] })` 可以只构建指定的小写 kebab-case ID。 + +## 作者格式 + +```ts +// src/hooks/policy/hook.ts +import type { Hook } from '@tokenroll/acplugin-extension-hooks'; + +export default { + event: 'PreToolUse', + matcher: 'Bash|Write|Edit', + timeout: 10, + async run(input, context) { + return input.toolName === 'Bash' + ? { decision: 'allow' } + : { decision: 'deny', reason: `Denied on ${context.platform}.` }; + }, +} satisfies Hook<'PreToolUse'>; +``` + +Portable events 是 `SessionStart`、`SessionEnd`、`UserPromptSubmit`、`PreToolUse`、`PermissionRequest`、`PostToolUse`、`PreCompact`、`PostCompact`、`SubagentStart`、`SubagentStop`、`Stop`。平台专属事件必须写成 `{ platform, name }`。 + +## 运行与安全边界 + +Extension 通过 Core `portable-node` Compiler 把每个 handler bundle 一次为自包含 Node 20 ESM。经过验证的平台 wire profile 与 runner 一同进入 Bundle,负责目标 stdin schema、camelCase 转换、root/data 映射和 stdout 协议;Contributor 不再补写相邻运行时 JavaScript。共享 runner 限制输入输出为 1 MiB、捕获顶层错误并只发稳定错误码。 + +作者不能声明 shell command、绝对 executable、HTTP callback 或其他原始目标协议。第三方依赖进入 bundle 时生成相邻 `THIRD_PARTY_LICENSES.txt`。 + +事件/字段兼容性见[完整矩阵](/resources/compatibility-matrix)。strict 模式拒绝 degraded/unsupported;relaxed 也只会生成验证过的 handler。 + +[Hooks package API](/api/@tokenroll/acplugin-extension-hooks/) diff --git a/packages/docs/extensions/index.md b/packages/docs/extensions/index.md new file mode 100644 index 0000000..641a452 --- /dev/null +++ b/packages/docs/extensions/index.md @@ -0,0 +1,10 @@ +# 官方扩展 + +Hooks 与 MCP 是独立公开 Extension package。它们通过 Core Compiler 只构建一次作者能力,再用官方 Contributor add-only 参与已配置 Platform 的 Package。 + +- [Hooks](./hooks.md)统一书写语义 handler,由对应 Contributor/wire profile 拥有目标 stdin/stdout 协议。 +- [MCP](./mcp.md)覆盖 portable HTTP 声明与完整 local stdio server。 + +Node Runtime 是 Core 内建 Framework Resource,参见[内建 Node Runtime](/guide/node-runtime)。 + +Extension 不是 Core 的 optional flag。只有安装 package、在 `extensions` 中实例化后,相应作者目录才合法。 diff --git a/packages/docs/extensions/mcp.md b/packages/docs/extensions/mcp.md new file mode 100644 index 0000000..efd7099 --- /dev/null +++ b/packages/docs/extensions/mcp.md @@ -0,0 +1,58 @@ +# MCP Extension + +## 安装与启用 + +```bash +pnpm add -D @tokenroll/acplugin-extension-mcp +``` + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import mcp from '@tokenroll/acplugin-extension-mcp'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [claudeCode()], + extensions: [mcp()], +}); +``` + +`mcp({ include: ['docs'] })` 可以只构建指定 Server。 + +## Remote HTTP + +```ts +// src/mcp/docs/mcp.ts +import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; + +export default { + transport: 'http', + url: 'https://example.com/mcp', + auth: { type: 'bearer', env: 'DOCS_TOKEN' }, + headers: { 'X-Tenant': { env: 'TENANT_ID' } }, +} satisfies McpServer; +``` + +Secret 只用 `{ env }` 引用,构建过程不会读取值。Production endpoint 必须 HTTPS;development 仅允许 loopback HTTP。 + +## Local stdio + +```ts +// src/mcp/local-tools/mcp.ts +import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; + +export default { + transport: 'stdio', + entry: 'server.ts', + env: { API_TOKEN: { env: 'LOCAL_API_TOKEN' } }, +} satisfies McpServer; +``` + +本地 Server 必须是完整实现。Extension 通过 Core `portable-node` Compiler bundle Node 20 ESM,拒绝未解析动态 import,并使用声明的 literal 环境值执行有界 `initialize → initialized → tools/list` smoke;Secret 引用不会被读取。 + +HTTP 在 Claude Code、Codex、Cursor、Antigravity、OpenCode 原生,Pi unsupported。Local stdio 在 Claude Code、Codex、OpenCode 原生,其余平台 unsupported。 + +[MCP package API](/api/@tokenroll/acplugin-extension-mcp/) diff --git a/packages/docs/guide/build-and-validate.md b/packages/docs/guide/build-and-validate.md new file mode 100644 index 0000000..2411c96 --- /dev/null +++ b/packages/docs/guide/build-and-validate.md @@ -0,0 +1,37 @@ +# 构建与校验 + +## 四个项目命令 + +- `validate`:执行完整 Package 生成、物化与平台校验,但不提交托管输出。 +- `inspect`:执行同一流水线,并在人类可读输出中列出 Component、Extension、Platform、Package 与 Asset。 +- `build`:全部所选 Platform 通过后,按事务替换受管 Package 集合。 +- `dev`:由 Core 监听真实资源与 Compiler 模块图,每个合并变更轮次执行同一 BuildSession,并保留最后一次成功输出。 + +它们共享 `--config`、`--platform`、`--mode` 和 `--json`,CLI 与程序化 API 不维护第二条构建路径。兼容性严格度只从 `build.strict` 和 Platform factory override 解析。 + +## 固定 lifecycle + +```text +config/session setup → resource discovery → validation/compile +→ Platform base Package → unordered Contributions → Core merge +→ finalization → primary/distribution candidate validation +→ compatibility/metadata → aggregate validation → transaction → Session close +``` + +完整阶段和 capability 边界见 [Lifecycle 契约](/ecosystem/lifecycle-contract)。 + +## 兼容性与提交 + +每个资源按 Platform 记录 `native`、`transform`、`degraded` 或 `unsupported`。strict 模式拒绝 degraded/unsupported;relaxed 只放宽功能兼容性,不放宽结构、来源、路径、owner、候选或事务错误。 + +事务以所选 Platform 的完整 Package 集合为单位:锁、恢复、stage、校验、transaction/backup、swap、cleanup。commit 的 Session close 仍位于可回滚窗口;任一目标或必要 cleanup 失败都保留上一份完整输出。 + +## 稳定 JSON + +CI 建议使用: + +```bash +pnpm exec acplugin validate --json > build-report.json +``` + +schema-v3 `BuildReport` 包含 Framework/Compiler 版本、Components、Runtimes、Extensions、Platforms、Packages、Asset 元数据、兼容性、metadata disposition 与阶段诊断。由 Platform Component Contribution 决定的生成 Asset 还会带稳定的 Extension owner/subject provenance。它不包含 Asset bytes、时间戳、凭据、临时路径或机器绝对路径,集合使用稳定顺序。 diff --git a/packages/docs/guide/cli.md b/packages/docs/guide/cli.md new file mode 100644 index 0000000..e0b5f13 --- /dev/null +++ b/packages/docs/guide/cli.md @@ -0,0 +1,39 @@ +# CLI + +## 初始化 + +```bash +pnpm exec acplugin init [directory] [options] +``` + +常用选项:`--yes`、`--name`、`--display-name`、`--description`、`--platform `、`--hooks`、`--mcp`、`--node-runtime`、`--install`、`--json`。默认脚手架选择 Claude Code 与 Codex;Hooks/MCP Extension 都是显式 opt-in,`--node-runtime` 则生成 Core 内建约定入口,不添加 Extension。 + +## 项目流水线 + +```bash +pnpm exec acplugin validate +pnpm exec acplugin inspect +pnpm exec acplugin build +pnpm exec acplugin dev +``` + +共享选项: + +| 选项 | 含义 | +| --- | --- | +| `-c, --config ` | 使用另一个 TypeScript 配置文件 | +| `--platform ` | 只运行配置中已实例化的平台子集 | +| `--mode development\|production` | 传给函数式配置的模式 | +| `--json` | stdout 只输出一个稳定 JSON 报告 | + +未知、重复、空或未配置的 `--platform` 会失败。旧 `--target` 已删除,不是兼容 alias。项目构建的 strict 策略通过 `acplugin.config.ts` 中的 `build.strict` 或 Platform factory override 配置。 + +## Migration + +```bash +pnpm exec acplugin migrate [destination] [options] +``` + +支持本地路径和受支持的 GitHub 来源。`--dry-run` 在临时存储中生成并验证;`--strict` 在存在 degraded/unmapped 资源时失败。详细边界见 [Migration](./migration.md)。 + +CLI 使用错误与构建失败使用非零退出码。机器消费时始终加 `--json`,不要解析人类可读文本。 diff --git a/packages/docs/guide/commands-skills-agents.md b/packages/docs/guide/commands-skills-agents.md new file mode 100644 index 0000000..e948f77 --- /dev/null +++ b/packages/docs/guide/commands-skills-agents.md @@ -0,0 +1,56 @@ +# Commands、Skills 与 Agents + +三类 Component 都使用 YAML Frontmatter + 非空 Markdown 正文,并可通过 `requires` 建立依赖图。缺失依赖、自依赖和循环依赖都会在 Scanner 阶段失败。 + +## Command + +```md +--- +description: Deploy the selected service +argumentHint: " [environment]" +requires: + skills: + - release-policy +--- + +Deploy `{{arguments}}` only after checking the release policy. +``` + +允许字段是 `description`、`argumentHint`、`requires`、`platforms`。`{{arguments}}` 是唯一规范参数占位符;是否保留参数 UI 由 Platform 的兼容性报告说明。 + +## Skill + +```md +--- +description: Apply the repository release policy +invocation: + user: true + model: true +--- + +Read [the checklist](references/checklist.md) before approving a release. +``` + +`invocation.user` 与 `invocation.model` 默认为 `true`,不能同时为 `false`。`SKILL.md` 之外的普通文件会以 binary-safe 方式作为 auxiliary files 处理。 + +## Agent + +```md +--- +description: Investigate source and return an evidence-backed report +model: capable +capabilities: + - filesystem:read + - search +--- + +Inspect the requested area, cite file locations, and stop after reporting evidence. +``` + +`model` 只接受 `inherit`、`fast`、`capable`。可移植 capabilities 是 `filesystem:read`、`filesystem:write`、`search`、`shell`、`network`、`delegate`。 + +## 平台专属字段 + +Frontmatter 的 `platforms.` 只在对应 Platform 已配置时合法,并由该 Platform 自己验证。Core 不维护具体平台字段,也不会把未知对象透传到产物。 + +查看[兼容性矩阵](/resources/compatibility-matrix)了解三类 Component 在六个平台上的处理方式。 diff --git a/packages/docs/guide/getting-started.md b/packages/docs/guide/getting-started.md new file mode 100644 index 0000000..0ebd448 --- /dev/null +++ b/packages/docs/guide/getting-started.md @@ -0,0 +1,61 @@ +# 快速开始 + +## 环境要求 + +作者工程使用 pnpm、ESM 和 TypeScript。公开包当前支持 Node.js `^20.19.0 || ^22.13.0 || >=23.5.0`。 + +## 用 CLI 初始化 + +下面的命令创建一个私有工程,默认显式安装并配置 Claude Code 与 Codex: + +```bash +pnpm dlx @tokenroll/acplugin init my-plugin --yes --install +cd my-plugin +pnpm validate +pnpm build +``` + +`init` 的默认平台只属于脚手架;运行时没有隐藏默认值。最终 `acplugin.config.ts` 总是包含独立 package imports 和必填的 `platforms` 数组。 + +## 手工建立最小工程 + +```bash +pnpm add -D @tokenroll/acplugin \ + @tokenroll/acplugin-platform-claude-code \ + @tokenroll/acplugin-platform-codex +``` + +```ts +// acplugin.config.ts +import { defineConfig } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [claudeCode(), codex()], +}); +``` + +添加一个 Skill: + +```md + +--- +description: Review a change and report actionable findings +--- + +Inspect the requested change, verify each finding against source, and report severity. +``` + +然后运行: + +```bash +pnpm exec acplugin validate +pnpm exec acplugin inspect +pnpm exec acplugin build +``` + +默认输出目录是 `dist/`。它是框架完整托管的目录,不要在其中保存手写文件。继续阅读[工程结构](./project-structure.md)和[构建与校验](./build-and-validate.md)。 diff --git a/packages/docs/guide/index.md b/packages/docs/guide/index.md new file mode 100644 index 0000000..46bd5df --- /dev/null +++ b/packages/docs/guide/index.md @@ -0,0 +1,14 @@ +# 指南 + +从安装、初始化和第一次构建开始,再逐步理解 Canonical Components、兼容性、CLI 与 Migration 边界。 + +- [为什么使用 ACPlugin](./why-acplugin.md):理解 Canonical 工程与平台交付的分工。 +- [快速开始](./getting-started.md):创建并构建第一个工程。 +- [工程结构](./project-structure.md):认识被 Scanner 消费的目录。 +- [Commands、Skills 与 Agents](./commands-skills-agents.md):编写三类规范资源。 +- [构建与校验](./build-and-validate.md):理解兼容性、报告和事务。 +- [CLI](./cli.md):选择适合开发或 CI 的命令。 +- [Migration](./migration.md):隔离地迁移旧 Claude 工程。 +- [故障排查](./troubleshooting.md):按稳定诊断码定位问题。 + +如果你只想尽快看到产物,直接进入[快速开始](./getting-started.md)。准备开发第三方集成时,再阅读[生态开发](/ecosystem/)。 diff --git a/packages/docs/guide/migration.md b/packages/docs/guide/migration.md new file mode 100644 index 0000000..18de034 --- /dev/null +++ b/packages/docs/guide/migration.md @@ -0,0 +1,21 @@ +# Migration + +Migration 用于把旧 Claude 工程或 Plugin 转成新的 Canonical 源码。它位于主包的隔离 lazy chunk 中,正常 CLI 启动、Core、Platform 与 Extension 都不会 import legacy Scanner。 + +```bash +pnpm exec acplugin migrate ./legacy-project ./new-project --dry-run --json +``` + +## 安全边界 + +- 目标必须是新的或空目录;不会原地写旧工程。 +- 可安全映射的 Commands、Skills、Agents 进入 Canonical 目录。 +- Instructions、raw Hooks、外部命令 MCP 和其他不能安全映射的内容进入 `.acplugin-migration/unmapped/`。 +- 旧 Hook 引用文件只作为待人工迁移材料保留,不会伪装成 typed Hook。 +- 报告稳定列出每项来源、去向和未映射原因。 + +## Marketplace 来源 + +GitHub Marketplace 输入可通过 `--plugin ` 选择一个 Plugin,或用 `--all` 迁移全部。`--path` 指定仓库内子路径。 + +迁移完成后仍应手工检查 unmapped 内容,再在新目录中安装依赖并运行 `validate`。Migration 的容错读取不改变 Core 对新工程的严格类型规则。 diff --git a/packages/docs/guide/node-runtime.md b/packages/docs/guide/node-runtime.md new file mode 100644 index 0000000..a3dd41a --- /dev/null +++ b/packages/docs/guide/node-runtime.md @@ -0,0 +1,45 @@ +# 内建 Node Runtime + +Node Runtime 是 Core Framework Resource,不需要安装 Extension 或调用额外 factory。启用后,Core 负责发现、编译、模块图 Watch、许可证和跨平台 Asset 交付。 + +## 约定入口 + +`src/runtime/` 的一级可执行 TypeScript/JavaScript 文件会自动成为入口,文件名去除扩展名后就是小写 kebab-case ID。嵌套文件只作为依赖: + +```text +src/runtime/ +├── cli.ts # entry id: cli +└── internal/client.ts # dependency only +``` + +自动入口默认是 `executable`,生成 `runtime/cli/main.mjs`,mode 为 `0755`。 + +## 显式入口 + +当入口 ID、文件位置或 kind 需要明确控制时,在配置中声明 `runtime.entries`。该字段会完整替换自动发现: + +```ts +export default defineConfig({ + // ...metadata and platforms + runtime: { + target: 'node20', + entries: { + cli: { entry: 'cli.ts', kind: 'executable' }, + library: { entry: 'modules/library.ts', kind: 'module' }, + }, + compile: { + treeshake: true, + }, + }, +}); +``` + +入口路径相对于 `/runtime`,不能使用绝对路径或 `..`。`module` 的 mode 为 `0644`。`runtime: false` 显式关闭该资源;目录存在内容但关闭时会失败,避免静默遗漏。 + +## 构建与交付 + +Core 使用 `portable-node` profile 把每个入口编译为自包含 Node 20 ESM。npm 依赖默认进入 Bundle,只有 `node:` 内置模块保持 external。未解析 import、原生扩展、隐式运行时依赖、作者源码 symlink 与特殊文件都会失败。 + +包含第三方 package 时,会在相邻路径生成稳定排序的 `THIRD_PARTY_LICENSES.txt`;无法确认许可证信息时失败,无第三方依赖时不生成空文件。相同输入产生相同 Bundle 字节,报告只记录工程相对 origin。 + +当前 Claude Code 与 Codex 声明稳定的 Plugin-local Node 能力并继承同一 Asset 字节。Cursor、Antigravity、OpenCode 与 Pi 报告 `unsupported` 且不生成伪 Runtime。空目录或没有有效入口时不产生 Asset 和兼容性噪声。 diff --git a/packages/docs/guide/project-structure.md b/packages/docs/guide/project-structure.md new file mode 100644 index 0000000..37f9f02 --- /dev/null +++ b/packages/docs/guide/project-structure.md @@ -0,0 +1,46 @@ +# 工程结构 + +一个完整作者工程可以包含: + +```text +acplugin.config.ts +src/ +├── commands/ +│ └── deploy.md +├── skills/ +│ └── review/ +│ ├── SKILL.md +│ └── references/checklist.md +├── agents/ +│ └── investigator.md +├── hooks/ # 启用 Hooks Extension 时 +│ └── session-start/hook.ts +├── mcp/ # 启用 MCP Extension 时 +│ └── docs/mcp.ts +└── runtime/ # 可选 Core Runtime 约定 + ├── cli.ts # 一级文件自动成为入口 + └── internal/helper.ts # 嵌套文件只作为依赖 +public/ +└── templates/report.md +``` + +## Scanner 管理的内容 + +- Command 必须是 `src/commands/.md` 的一级 Markdown 文件。 +- Skill 必须是 `src/skills//SKILL.md`;同目录其他普通文件是 auxiliary resources。 +- Agent 必须是 `src/agents/.md` 的一级 Markdown 文件。 +- ID 使用小写 kebab-case。Markdown 必须有合法 YAML Frontmatter 和非空正文。 + +`srcDir` 可在配置中修改,但三个 Canonical 目录的相对结构不变。符号链接、特殊文件和目录层级错误会被拒绝。 + +## Extension 与 Runtime 管理的内容 + +`src/hooks` 与 `src/mcp` 不是 Core Component,由相应 Extension 的 `discover` 阶段拥有;目录非空但未启用 Extension 时,构建会失败。`src/runtime` 是 Core Framework Resource:一级 TS/JS 文件按约定成为入口,显式 `runtime.entries` 完整替换自动发现,`runtime: false` 关闭该能力。Descriptor 由 Core Module Service 执行,所有可执行代码由 Core Rolldown Build Service 构建。 + +## Public + +默认 `public/` 中的普通文件会成为每个 Platform base Package 的 Framework Contribution。也可以通过 [Public 配置](/config/public-files)关闭、改目录或只复制选定路径。 + +## 输出 + +`dist/` 由事务层完整管理。Platform 和 Extension 只能把 Core 签发或授权的 AssetRef 映射到 Package;物理 workDir 与 `dist` 写入都只由 Core 管理。 diff --git a/packages/docs/guide/troubleshooting.md b/packages/docs/guide/troubleshooting.md new file mode 100644 index 0000000..5c0a2c0 --- /dev/null +++ b/packages/docs/guide/troubleshooting.md @@ -0,0 +1,29 @@ +# 故障排查 + +优先使用 `--json` 获取稳定诊断码、phase、fieldPath 和安全 location,不要依赖可能调整的人类文案。 + +## 配置失败 + +- `CONFIG_PLATFORMS_REQUIRED`:安装 Platform package,并把至少一个工厂结果放入必填 `platforms`。 +- `CONFIG_PLATFORM_INVALID`:不能手写 shape;使用官方工厂或公开 `definePlatform()`。 +- `CONFIG_DIRECTORY_OVERLAP`:`srcDir` 与 `build.outDir` 必须是分离的工程子树。 +- `CONFIG_PUBLIC_OVERLAP`:Public 完整目录或 copy source 不能与源码、输出或配置入口重叠。 + +## Resource discovery 或 Canonical validation 失败 + +- `FRONTMATTER_REQUIRED`:文件第一行必须是 `---`。 +- `MARKDOWN_BODY_REQUIRED`:Frontmatter 后必须有非空正文。 +- `SOURCE_ROOT_INVALID` / `SOURCE_ROOT_CONTENT_INVALID` / `RESOURCE_ROOT_CONTENT_INVALID`:使用工程内普通目录和文件,移除符号链接、特殊文件与规范化冲突。 +- `COMPONENT_DEPENDENCY_MISSING` / `COMPONENT_DEPENDENCY_SELF` / `COMPONENT_DEPENDENCY_CYCLE`:检查 `requires` 中的 ID、自依赖和完整循环。 + +## Extension 失败 + +发现 `src/hooks` 或 `src/mcp` 内容却未启用对应 Extension 时,安装独立 package 并添加 `hooks()` 或 `mcp()`。`src/runtime` 由 Core 直接扫描;使用 `runtime.entries` 修正入口映射,或用 `runtime: false` 显式关闭。不要删除诊断或把目录放进 Public 来绕过验证。 + +## strict 失败 + +先运行 `inspect --json` 查看是哪一资源为 degraded/unsupported。确认目标平台确实允许降级后,才在全局 `build.strict` 或单个 Platform factory 中显式放宽。结构和安全错误不会被放宽。 + +## 事务或锁失败 + +不要手工删除未知 transaction/backup 内容。重新运行命令会先执行恢复;若持续失败,保留完整脱敏报告和目录结构再提交问题。 diff --git a/packages/docs/guide/why-acplugin.md b/packages/docs/guide/why-acplugin.md new file mode 100644 index 0000000..2f89c9f --- /dev/null +++ b/packages/docs/guide/why-acplugin.md @@ -0,0 +1,39 @@ +# 为什么使用 ACPlugin + +AI 编程平台通常使用不同目录、Manifest 和运行协议表达相似能力。直接维护六套输出会让内容、兼容性判断、编译方式和安全边界逐渐分叉。ACPlugin 把作者模型与目标交付分开:作者维护一份 Canonical 工程,Platform 负责目标 Package,Core 提供统一 Rolldown Compiler 与固定生命周期。 + +## 一条固定流水线 + +```text +Config → Core Resource discovery → CanonicalProject + → Platform base Package → unordered add-only Contributions + → finalized/validated Packages → transaction → BuildReport +``` + +Core 拥有阶段、Compiler/Module/Watch、诊断、Asset、兼容性和事务。Platform 拥有目标 Component 转换、结构化 Document、Package identity、Distribution 与 candidate validator。Extension 构建一次平台中立状态,再通过只读 base Package 上的 Contributor 添加横向能力。 + +这意味着: + +- CLI、`runProject()`、`Project.run()` 与 `Project.dev()` 走同一 Kernel。 +- Platform/Extension 不能自建 bundler、watcher 或直接写入 `dist`。 +- 一个目标失败时不会提交部分新 Package 集合。 +- 兼容性逐资源显式报告,不能静默丢弃能力。 +- 第三方实现使用 `@tokenroll/acplugin/sdk`,不需要中央 registry 或 Core 私有 API。 + +## 独立 package,而不是主包开关 + +主包不重新导出官方 Platform 或 Extension。工程安装什么、实例化什么,就是构建图中存在什么: + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import cursor from '@tokenroll/acplugin-platform-cursor'; + +export default defineConfig({ + name: 'review-tools', + version: '1.0.0', + description: 'Shared review workflows.', + platforms: [cursor()], +}); +``` + +Node Runtime 是 Core 内建约定,不是第三个 Extension package。接下来阅读[快速开始](./getting-started.md),或查看[package map](/resources/package-map)。 diff --git a/packages/docs/index.md b/packages/docs/index.md new file mode 100644 index 0000000..8484297 --- /dev/null +++ b/packages/docs/index.md @@ -0,0 +1,84 @@ +--- +layout: home + +hero: + name: ACPlugin + text: 一次创作,多平台交付 + tagline: 用一套 Canonical 工程,稳定构建 Claude Code、Codex、Cursor、Antigravity、OpenCode 与 Pi 交付产物。 + image: + src: /acplugin-mark.svg + alt: 香蕉形字母 C 组成的 ACPlugin 标志 + actions: + - theme: brand + text: 🍌 快速开始 + link: /guide/getting-started + - theme: alt + text: 浏览 API + link: /api/ + +features: + - icon: ✍️ + title: Canonical Authoring + details: 用 Commands、Skills、Agents 与可选 Extensions 表达作者意图。 + - icon: 🧬 + title: 固定生命周期 + details: Core 统一扫描、兼容性、所有权、事务和稳定报告。 + - icon: 🎯 + title: 六平台交付 + details: 官方 Platform 独立安装,把同一份内容转换成目标平台的原生结构。 + - icon: 🧩 + title: 开放生态 + details: Platform 与 Extension 都从独立 package 安装和导入。 + - icon: 🛡️ + title: 安全事务 + details: owner 隔离、候选校验与全量提交共同保护已有 dist。 + - icon: 📐 + title: 确定性输出 + details: 稳定排序、序列化和报告让相同输入产生可审查的相同字节。 +--- + +## 一个工程,明确的交付边界 + +ACPlugin 把平台中立的作者资源交给显式配置的 Platform,并通过无序、add-only Extension Contribution 添加横向能力。先从[快速开始](/guide/getting-started)了解工程结构,或直接查看[公开 API](/api/)。 + +
+

🍌 One source, many deliveries

+
+
+ + Canonical Authoring +
+ +
+ + Core Lifecycle +
+ +
+ + Platform Delivery +
+ +
+ + Optional Extensions +
+
+
+ +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import hooks from '@tokenroll/acplugin-extension-hooks'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [claudeCode(), codex()], + extensions: [hooks()], +}); +``` + +Platform 与 Extension 是独立 package。主包只提供 CLI、配置、生命周期 SDK 和通用契约,因此官方实现与第三方实现遵守同一条边界。 diff --git a/packages/docs/package.json b/packages/docs/package.json new file mode 100644 index 0000000..e7b96dc --- /dev/null +++ b/packages/docs/package.json @@ -0,0 +1,24 @@ +{ + "name": "@acplugin/docs", + "version": "0.0.1-beta", + "private": true, + "type": "module", + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "scripts": { + "api": "typedoc --options typedoc.json", + "predev": "pnpm run api", + "dev": "vitepress dev .", + "prebuild": "pnpm run api", + "build": "vitepress build .", + "preview": "vitepress preview ." + }, + "devDependencies": { + "typedoc": "^0.28.20", + "typedoc-plugin-markdown": "^4.12.0", + "typedoc-vitepress-theme": "^1.1.3", + "typescript": "npm:@typescript/typescript6@^6.0.2", + "vitepress": "^1.6.4" + } +} diff --git a/packages/docs/platforms/antigravity.md b/packages/docs/platforms/antigravity.md new file mode 100644 index 0000000..b466517 --- /dev/null +++ b/packages/docs/platforms/antigravity.md @@ -0,0 +1,37 @@ +# Antigravity + +## 安装与配置 + +```bash +pnpm add -D @tokenroll/acplugin @tokenroll/acplugin-platform-antigravity +``` + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import antigravity from '@tokenroll/acplugin-platform-antigravity'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [antigravity({ strict: false })], +}); +``` + +## Options + +当前只开放 `strict?: boolean`。官方公开 Manifest 契约尚未确认的字段不会被猜测性写入。 + +## 交付与兼容性 + +Plugin 根使用最小 `plugin.json` 和 `skills/`: + +- Skill 是 native;不能表达 invocation 开关时按字段报告 degraded。 +- Command 转成带固定前缀的显式 Skill;argument hint UI 不可用时 degraded。 +- Agent 转成指导型 Skill,model/capabilities 无法强制,因此 degraded。 + +除 `name` 外,统一元数据会按实际声明报告 omitted 与 warning,而不是写入未经确认的 Manifest 字段。包含 Agent 的工程需要显式审查 strict 策略。 + +Antigravity 当前不支持 Platform Component Contribution。非空私有 contribution 会在 finalization 失败,绝不会被伪装为 Skill。 + +[Antigravity package API](/api/@tokenroll/acplugin-platform-antigravity/) diff --git a/packages/docs/platforms/claude-code.md b/packages/docs/platforms/claude-code.md new file mode 100644 index 0000000..f1a6857 --- /dev/null +++ b/packages/docs/platforms/claude-code.md @@ -0,0 +1,45 @@ +# Claude Code + +## 安装与配置 + +```bash +pnpm add -D @tokenroll/acplugin @tokenroll/acplugin-platform-claude-code +``` + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [claudeCode()], +}); +``` + +## Options + +- `strict?: boolean`:覆盖全局兼容性严格度。 +- `defaultEnabled?: boolean`:写入 Claude Code Plugin Manifest。 +- `marketplace?: { name?, owner?, category?, tags? }`:额外生成自包含 Marketplace。省略时只生成 Plugin。 + +```ts +claudeCode({ + marketplace: { + owner: { name: 'Example Team' }, + category: 'Developer Tools', + tags: ['workflow'], + }, +}) +``` + +## 交付与兼容性 + +主 Plugin 包含 `.claude-plugin/plugin.json`、`commands/`、`skills/` 与 `agents/`。Command 的 `{{arguments}}` 转为原生 `$ARGUMENTS`;三类 Component 均为 native。 + +Hooks/MCP 由独立 Extension Contributor 向 Manifest 的受控扩展点写入。Platform 本身不 import Extension package。配置 `marketplace` 时还生成 `.claude-plugin/marketplace.json`,并继承已经验证的完整主 Plugin Asset。 + +可信 Extension 也可通过本 package 的 `ClaudePackageComponent` 交付私有原生 Agent。Claude Code 在 finalization 自己校验 payload、处理与 canonical Agent 的命名冲突、生成 `agents/.md` 并受控写入 `agents` Manifest 字段;Extension 不能直接写这些路径或 Manifest。 + +[Claude Code package API](/api/@tokenroll/acplugin-platform-claude-code/) diff --git a/packages/docs/platforms/codex.md b/packages/docs/platforms/codex.md new file mode 100644 index 0000000..b3a5a3a --- /dev/null +++ b/packages/docs/platforms/codex.md @@ -0,0 +1,41 @@ +# Codex + +## 安装与配置 + +```bash +pnpm add -D @tokenroll/acplugin @tokenroll/acplugin-platform-codex +``` + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import codex from '@tokenroll/acplugin-platform-codex'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [codex()], +}); +``` + +## Options + +- `strict?: boolean`:覆盖兼容性严格度。 +- `interface?`:安装界面的描述、开发者、分类、URL、颜色、图标、截图和默认 prompt。 +- `marketplace?`:可选 Marketplace 名称、展示名、分类和 installation policy。 + +所有字段都通过受控 Schema 校验,不接受任意 Manifest 透传。 + +## 交付与兼容性 + +主 Plugin 以 `.codex-plugin/plugin.json` 和 `skills/` 为核心: + +- Canonical Skill 保持 native。 +- Command 转换为 `skills/-`。参数占位符变成显式调用指导;`argumentHint` 无 UI 时为 degraded。 +- Agent 转换为 `skills/agent-` 的指导型 Skill,model/capabilities 只保留为文本,因此为 degraded。 + +存在 Agent 的工程默认 strict 会失败;只有明确接受这一降级时才使用 `codex({ strict: false })`。可选 Marketplace 写入 `.agents/plugins/marketplace.json`。 + +Codex 当前不支持 Platform Component Contribution。非空私有 contribution 会在 finalization 失败,绝不会被伪装为 `agent-*` Skill。 + +[Codex package API](/api/@tokenroll/acplugin-platform-codex/) diff --git a/packages/docs/platforms/cursor.md b/packages/docs/platforms/cursor.md new file mode 100644 index 0000000..eed569d --- /dev/null +++ b/packages/docs/platforms/cursor.md @@ -0,0 +1,37 @@ +# Cursor + +## 安装与配置 + +```bash +pnpm add -D @tokenroll/acplugin @tokenroll/acplugin-platform-cursor +``` + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import cursor from '@tokenroll/acplugin-platform-cursor'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [cursor()], +}); +``` + +## Options + +`CursorPlatformOptions` 支持 `strict`、`publisher`、`logo`、`category`、`tags` 和按客户端 ID 映射的 `minClientVersions`。Logo 只能是安全相对路径或合法远程 URL。 + +## 交付与兼容性 + +Plugin 包含 `.cursor-plugin/plugin.json`、`commands/`、`skills/` 与 `agents/`。三类 Component 都有原生表示,但字段仍逐项报告: + +- Command `argumentHint` 在目标 UI 不可表达时 degraded。 +- Skill 无法关闭显式 user invocation 时 degraded。 +- Agent model 或 capability 无法精确强制时 degraded。 + +Hooks/MCP 的具体事件或 transport 支持由对应 Extension Contributor 报告,Platform 只提供受控 Manifest 扩展点。 + +可信 Extension 可通过 `CursorPackageComponent` 交付私有原生 Subagent。Cursor 在 finalization 校验 payload、处理 canonical Agent 的命名冲突、生成 `agents/.md` 并受控写入 `agents` glob;Extension 不能直接 patch Plugin Manifest。 + +[Cursor package API](/api/@tokenroll/acplugin-platform-cursor/) diff --git a/packages/docs/platforms/index.md b/packages/docs/platforms/index.md new file mode 100644 index 0000000..00abc6d --- /dev/null +++ b/packages/docs/platforms/index.md @@ -0,0 +1,14 @@ +# 官方平台 + +六个官方 Platform 都是独立 package。工程只安装和实例化需要的目标平台。 + +| Platform | Package | 主 Package | Component 策略 | +| --- | --- | --- | --- | +| [Claude Code](./claude-code.md) | `@tokenroll/acplugin-platform-claude-code` | Plugin / 可选 Marketplace | Command、Skill、Agent 原生 | +| [Codex](./codex.md) | `@tokenroll/acplugin-platform-codex` | Plugin / 可选 Marketplace | Skill 原生,Command 转 Skill,Agent 降级为 Skill | +| [Cursor](./cursor.md) | `@tokenroll/acplugin-platform-cursor` | Plugin | Command、Skill、Agent 原生,部分字段可能降级 | +| [Antigravity](./antigravity.md) | `@tokenroll/acplugin-platform-antigravity` | Plugin | Skill 原生,Command 转 Skill,Agent 降级为 Skill | +| [OpenCode](./opencode.md) | `@tokenroll/acplugin-platform-opencode` | Workspace | 三类资源原生,capability 转 tools/permissions | +| [Pi](./pi.md) | `@tokenroll/acplugin-platform-pi` | npm Package | Command 转 Prompt,Skill 原生,Agent 降级为 Skill | + +工厂的 `strict` 可以覆盖全局兼容性策略。更细粒度的差异见[兼容性矩阵](/resources/compatibility-matrix)。 diff --git a/packages/docs/platforms/opencode.md b/packages/docs/platforms/opencode.md new file mode 100644 index 0000000..fc26ae8 --- /dev/null +++ b/packages/docs/platforms/opencode.md @@ -0,0 +1,43 @@ +# OpenCode + +## 安装与配置 + +```bash +pnpm add -D @tokenroll/acplugin @tokenroll/acplugin-platform-opencode +``` + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import openCode from '@tokenroll/acplugin-platform-opencode'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [openCode({ workspace: { schema: true } })], +}); +``` + +## Options + +- `strict?: boolean`:覆盖兼容性严格度。 +- `workspace.schema?: boolean`:在按需生成的 `opencode.json` 中写入官方 JSON Schema URL。 + +不开放任意 workspace JSON 透传。 + +## 交付与兼容性 + +OpenCode 产生 workspace 主 Package,而不是安装型 Plugin: + +```text +.opencode/commands/ +.opencode/skills/ +.opencode/agents/ +opencode.json # 有配置或 Extension 内容时生成 +``` + +Command、Skill、Agent 都有原生 workspace 表示。Canonical capabilities 会转换为 OpenCode tools/permission 字段;无法精确固定 model 时按字段报告 degraded。HTTP 与 local stdio MCP 都可由官方 Contributor 加入 workspace 配置。 + +可信 Extension 可通过 `OpenCodePackageComponent` 交付私有原生 workspace Subagent。OpenCode 在 finalization 校验 payload、处理 canonical Agent 的命名冲突并生成 `.opencode/agents/.md`;该能力不创建或 patch `opencode.json`。 + +[OpenCode package API](/api/@tokenroll/acplugin-platform-opencode/) diff --git a/packages/docs/platforms/pi.md b/packages/docs/platforms/pi.md new file mode 100644 index 0000000..f8a2310 --- /dev/null +++ b/packages/docs/platforms/pi.md @@ -0,0 +1,39 @@ +# Pi + +## 安装与配置 + +```bash +pnpm add -D @tokenroll/acplugin @tokenroll/acplugin-platform-pi +``` + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import pi from '@tokenroll/acplugin-platform-pi'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [pi({ package: { image: './assets/cover.png' } })], +}); +``` + +## Options + +- `strict?: boolean`:覆盖兼容性严格度。 +- `package.image?: string`:相对 package 根或远程展示图片。 +- `package.video?: string`:远程演示视频 URL。 + +## 交付与兼容性 + +Pi 产生带 `package.json` 的 npm 主 Package: + +- Command 转换到 `prompts/.md`;参数提示可进入原生 prompt metadata。 +- Skill 进入 `skills//SKILL.md`,为 native。 +- Agent 转为指导型 `skills/agent-`,model/capabilities 不能强制,因此 degraded。 + +Pi 不支持 MCP transport,官方 MCP Contributor 会报告 unsupported,而不会伪造客户端行为。 + +Pi 当前不支持 Platform Component Contribution。非空私有 contribution 会在 finalization 失败,绝不会被伪装为指导型 Skill。 + +[Pi package API](/api/@tokenroll/acplugin-platform-pi/) diff --git a/packages/docs/playground/capability-template.md b/packages/docs/playground/capability-template.md new file mode 100644 index 0000000..ba068df --- /dev/null +++ b/packages/docs/playground/capability-template.md @@ -0,0 +1,56 @@ +# 全能力模板 + +`packages/playground` 是仓库内真实 ACPlugin consumer,以通用工程示例覆盖 Commands、Skill auxiliary、Agents、全部 portable Hooks、HTTP/local MCP 与 Public 文件,不绑定任何具体产品领域。 + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import hooks from '@tokenroll/acplugin-extension-hooks'; +import mcp from '@tokenroll/acplugin-extension-mcp'; +import antigravity from '@tokenroll/acplugin-platform-antigravity'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; +import cursor from '@tokenroll/acplugin-platform-cursor'; +import openCode from '@tokenroll/acplugin-platform-opencode'; +import pi from '@tokenroll/acplugin-platform-pi'; + +export default defineConfig({ + name: 'acplugin-playground', + version: '0.1.0', + description: 'Complete ACPlugin capability template for integration exercises.', + platforms: [claudeCode(), codex(), cursor(), antigravity(), openCode(), pi()], + runtime: { entries: { playground: { entry: 'main.ts' } } }, + extensions: [hooks(), mcp()], + build: { strict: false }, +}); +``` + +## 模板内容 + +- `init`、`update`、`prune`、`upgrade` 通用工程 Commands。 +- `project-workflow` Skill、四个工作流 references 与两个 Skill-local icon。 +- investigator、reflector、recorder Agents。 +- 全部 11 个 portable Hook 事件及其无副作用语义结果。 +- public、OAuth、Bearer 三类 remote HTTP MCP,以及完整 local stdio MCP。 +- 一个可真实执行、向 Claude Code/Codex 交付相同字节的 Node 20 ESM Runtime。 +- runtime/schema/upgrade 静态资源示例、品牌资源和四个 Public 模板。 +- 六个平台主 Package 与 Claude Code/Codex Marketplace Distribution。 + +## 输出验证 + +验证器消费真实 `validate --json` 和 `build --json`,逐项检查六平台兼容性矩阵、schema-v3 Package/Asset report 与文件树闭包、Component 转换内容、Manifest/Config 引用、Hook runtime、MCP JSON-RPC、Node Runtime 真实执行、Secret 不泄漏和双构建字节确定性。平台明确 unsupported 的事件、transport 或 runtime 必须出现在兼容性报告中,同时不得生成伪配置或伪运行文件。 + +## 模板边界 + +Playground 不实现具体产品业务,只展示作者工程结构、公开 API、平台转换、Extension 协议和交付产物验证。示例 Handler 与 Server 无持久化副作用。 + +Codex 会把 Command 转为 `-` Skill,Antigravity 会把 Command 转为 `command-*` Skill,Pi 转为 Prompt Template;Codex、Antigravity 和 Pi 会把 Agent 降级为 `agent-*` guidance Skill。这些是平台明确报告的兼容性结果。 + +Hooks、MCP 和 Core Runtime 提供可执行但无持久化副作用的协议模板;`public/schemas` 只展示静态资源交付位置,不构成业务 Schema。因此它是 ACPlugin 全能力 packaging/template smoke,不是平台官方 conformance suite。 + +## 运行 + +```bash +pnpm playground:check +``` + +配置使用 `strict: false` 是为了显式观察六平台的真实能力差异;结构、安全、owner、来源和事务错误仍必须失败。仓库 verifier 会精确接受已声明的 degradation/unsupported,同时验证这些不支持项没有生成伪 Asset。 diff --git a/packages/docs/playground/index.md b/packages/docs/playground/index.md new file mode 100644 index 0000000..816952d --- /dev/null +++ b/packages/docs/playground/index.md @@ -0,0 +1,5 @@ +# Playground + +仓库内 Playground 是不绑定具体产品领域的完整 ACPlugin packaging/template smoke,真实构建六个平台、全部 portable Hooks、HTTP/local MCP、Node Runtime 和两个 Marketplace。 + +[查看全能力模板说明](./capability-template.md) diff --git a/packages/docs/public/acplugin-logo-dark.svg b/packages/docs/public/acplugin-logo-dark.svg new file mode 100644 index 0000000..3bde5ee --- /dev/null +++ b/packages/docs/public/acplugin-logo-dark.svg @@ -0,0 +1,11 @@ + + ACPlugin + ACPlugin wordmark with a banana-shaped letter C. + + A + + + + Plugin + + diff --git a/packages/docs/public/acplugin-logo.svg b/packages/docs/public/acplugin-logo.svg new file mode 100644 index 0000000..9b59b38 --- /dev/null +++ b/packages/docs/public/acplugin-logo.svg @@ -0,0 +1,11 @@ + + ACPlugin + ACPlugin wordmark with a banana-shaped letter C. + + A + + + + Plugin + + diff --git a/packages/docs/public/acplugin-mark.svg b/packages/docs/public/acplugin-mark.svg new file mode 100644 index 0000000..046ef10 --- /dev/null +++ b/packages/docs/public/acplugin-mark.svg @@ -0,0 +1,17 @@ + + ACPlugin banana C + A banana-shaped letter C on a dark rounded square. + + + + + + + + + + + + + + diff --git a/packages/docs/resources/compatibility-matrix.md b/packages/docs/resources/compatibility-matrix.md new file mode 100644 index 0000000..d557707 --- /dev/null +++ b/packages/docs/resources/compatibility-matrix.md @@ -0,0 +1,47 @@ +# 兼容性矩阵 + +## Canonical Components + +| Platform | Command | Skill | Agent | +| --- | --- | --- | --- | +| Claude Code | Native | Native | Native | +| Codex | Transform → 固定 `-` explicit Skill | Native | Degraded → `agent-*` guidance Skill | +| Cursor | Native | Native | Native | +| Antigravity | Transform → explicit Skill | Native | Degraded → guidance Skill | +| OpenCode | Native workspace Command | Native workspace Skill | Native workspace Agent;capability 为 transform | +| Pi | Transform → Prompt Template | Native | Degraded → guidance Skill | + +表格只描述 Component 主能力。`argumentHint`、invocation、model、capabilities 等字段仍可能产生独立 degraded/transform 记录。 + +## Platform Component Contribution + +| Platform | 私有原生 Component contribution | +| --- | --- | +| Claude Code | Native Agent;Platform 渲染 `agents/.md` 并写受控 Manifest 字段 | +| Cursor | Native Subagent;Platform 渲染 `agents/.md` 并写受控 Manifest glob | +| OpenCode | Native workspace Subagent;Platform 渲染 `.opencode/agents/.md`,不 patch config | +| Codex | Unsupported;非空 contribution 在 finalization 失败,不生成 Skill fallback | +| Antigravity | Unsupported;非空 contribution 在 finalization 失败,不生成 Skill fallback | +| Pi | Unsupported;非空 contribution 在 finalization 失败,不生成 Skill fallback | + +此能力仅供 Extension 的 `PlatformContributor` 使用;不是 Canonical Component,也不引入 Extension 顺序、slot 或覆盖模型。payload schema、路径与冲突策略由各 Platform package 拥有。 + +## MCP transports + +| Transport | Claude Code | Codex | Cursor | Antigravity | OpenCode | Pi | +| --- | --- | --- | --- | --- | --- | --- | +| Remote HTTP | Native | Native | Native | Native | Native | Unsupported | +| Local stdio | Native | Native | Unsupported | Unsupported | Native | Unsupported | + +## Portable Hook events + +| Platform | Native | Transform/Degraded | Unsupported | +| --- | --- | --- | --- | +| Claude Code | 11 个 portable events | 部分事件 matcher degraded | — | +| Codex | 11 个 portable events | 部分事件 matcher degraded | — | +| Cursor | — | 9 events transformed;部分 matcher/status degraded | `PermissionRequest`、`PostCompact` | +| Antigravity | `SessionStart`、`SessionEnd`、`PreToolUse`、`PostToolUse`、`PreCompact` | 部分字段 degraded | 其余 6 events | +| OpenCode | `SessionStart`、`UserPromptSubmit`、`PreToolUse`、`PostToolUse`、`PostCompact` | `SessionEnd`、`Stop` degraded | 其余 4 events | +| Pi | `SessionStart`、`SessionEnd`、`UserPromptSubmit`、`PreToolUse`、`PostToolUse`、`PreCompact`、`PostCompact` | `Stop` degraded | `PermissionRequest`、两个 Subagent events | + +最终以 build report 为准。Platform/Extension 升级后,文档矩阵和实现测试必须一起更新。 diff --git a/packages/docs/resources/deterministic-builds.md b/packages/docs/resources/deterministic-builds.md new file mode 100644 index 0000000..f75e4ae --- /dev/null +++ b/packages/docs/resources/deterministic-builds.md @@ -0,0 +1,20 @@ +# 确定性构建 + +在相同工程字节、配置、ACPlugin/Platform/Extension 版本、Node major 和 lockfile 下,生成内容与稳定报告应保持字节一致。 + +## 作者责任 + +- 不在 config、Platform 或 Extension build 中读取未声明的机器环境来改变产物。 +- 不写时间戳、随机 ID、临时绝对路径或宿主目录。 +- 使用主包的 `stableJson()`、`stableYaml()` 和稳定报告序列化器。 +- 对文件和对象键采用明确稳定顺序。 +- Secret 只保留 `{ env }` 引用,不读取值。 + +## 框架保证 + +- Resource Provider 和 Registry 对目录、资源、Contribution 和报告使用稳定排序。 +- Asset 记录固定 owner、origin、mode、size 与 SHA-256。 +- 报告不包含 Asset bytes、时间、绝对路径、凭据或环境值。 +- TypeDoc/VitePress 文档 build 关闭 last-updated,不在线 fetch 内容。 + +确定性不是跨任意 Node/依赖版本的承诺。升级 Node major、lockfile 或生成器版本后,应把变化作为正常版本化 diff 审查。 diff --git a/packages/docs/resources/index.md b/packages/docs/resources/index.md new file mode 100644 index 0000000..65f36f7 --- /dev/null +++ b/packages/docs/resources/index.md @@ -0,0 +1,8 @@ +# 参考资源 + +这里汇总构建与生态边界中需要快速查阅的横向信息: + +- [兼容性矩阵](./compatibility-matrix.md) +- [确定性构建](./deterministic-builds.md) +- [安全模型](./security-model.md) +- [Package map](./package-map.md) diff --git a/packages/docs/resources/package-map.md b/packages/docs/resources/package-map.md new file mode 100644 index 0000000..f17ecf0 --- /dev/null +++ b/packages/docs/resources/package-map.md @@ -0,0 +1,29 @@ +# Package map + +## Framework + +| Package | 可见性 | 责任 | +| --- | --- | --- | +| `@tokenroll/acplugin` | Public | CLI、作者 façade、`/sdk` Integration 契约、程序化 Project API、隔离 Migration;构建时内联 Core | +| `@acplugin/core` | Private | Kernel、Resource Provider、Compiler/Module/Execution/Watch Host、Asset/Package Registry、兼容性、报告与 transaction | + +## Official Platforms + +`@tokenroll/acplugin-platform-claude-code`、`-codex`、`-cursor`、`-antigravity`、`-opencode`、`-pi` 都是公开独立 package,并以主包为 peer。主包不提供官方集成 subpath 或重导出。 + +## Official Extensions + +- `@tokenroll/acplugin-extension-hooks` +- `@tokenroll/acplugin-extension-mcp` + +Extension package 同时拥有作者格式、Built State 和面向六个平台的官方 Contributor;Platform 不反向依赖 Extension。 + +## Repository-only consumers + +| Workspace | 责任 | +| --- | --- | +| `@acplugin/test` | 跨包 Vitest、tarball 和架构验证 | +| `@acplugin/docs` | VitePress 与九个公开 package(含主包 `/sdk`)的 TypeDoc 生成 | +| `@acplugin/playground` | 领域中立的全能力 packaging/template smoke | + +Node Runtime 是 Core Framework Resource,因此没有 `@tokenroll/acplugin-extension-node-runtime`。Docs 与 Playground 都是私有消费者,不进入 Changesets 或 release tarball。 diff --git a/packages/docs/resources/security-model.md b/packages/docs/resources/security-model.md new file mode 100644 index 0000000..b2e6e59 --- /dev/null +++ b/packages/docs/resources/security-model.md @@ -0,0 +1,29 @@ +# 安全模型 + +ACPlugin 把 Markdown、路径、JSON 数据和待交付 Asset 视为不可信数据,直到对应阶段完成结构、来源和最终候选验证。TypeScript 配置、Platform、Extension 与其 descriptor 则是会在构建进程中执行的可信代码,安装或运行前必须像其他构建工具依赖一样审查。 + +## 可信 Integration 边界 + +Platform/Extension 与作者配置和 descriptor 不是进程沙箱。它们可以使用 Node.js 能力直接读取进程可访问的文件或环境;Core 不承诺阻止恶意 Integration。Core 的 owner-scoped Source、Module、Compiler、Execution 和 Asset Service 负责限制哪些来源和输出能进入受管 Package、报告与事务,并提供确定性和可审计边界。 + +Platform/Extension factory result 使用 `Symbol.for(...)` 的共享 registry brand,使同一生命周期 API 的主包 root、SDK 和 CLI bundle chunk 能识别同一类定义。该 Symbol 可被同进程代码访问或伪造,不是 private Symbol、权限令牌或安全边界;定义仍需通过精确 shape、API version、JSON copy/freeze 和生命周期校验。 + +## 路径与来源 + +- 配置路径必须在工程根内,输出不能与源码/Public 重叠。 +- Component、Skill auxiliary、Public、Platform 与 Extension 各有独立的 owner-scoped Source/Asset 授权;物理 workDir 只对 Core Host 可见。 +- 拒绝绝对路径、NUL、`..`、符号链接、特殊文件和规范化冲突。 +- `dist` 只由 transaction 层整体提交。 + +## 扩展协议 + +- Document Contribution 是 owner-aware add-only;所有 Contributor 读取同一 base Package,没有覆盖优先级。 +- Hook 作者只返回语义结果,wire 拥有目标协议;runner 有输入输出上限和稳定错误码。 +- HTTP MCP Secret 使用环境引用,构建不读取值。 +- Local stdio MCP 必须 bundle 并通过真实协议 smoke。 + +## 报告与错误 + +Session `close()` 只收到脱敏失败摘要。稳定报告不会输出凭据、环境值、Asset bytes、机器路径或临时路径。第三方 bundle 默认需要生成确定性许可材料。 + +Relaxed compatibility 不会绕过这些安全检查。 diff --git a/packages/docs/tsconfig.json b/packages/docs/tsconfig.json new file mode 100644 index 0000000..a970cd9 --- /dev/null +++ b/packages/docs/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": [".vitepress/**/*.ts", ".vitepress/**/*.mts"] +} diff --git a/packages/docs/typedoc.json b/packages/docs/typedoc.json new file mode 100644 index 0000000..addd3f5 --- /dev/null +++ b/packages/docs/typedoc.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "entryPoints": [ + "../acplugin", + "../platforms/claude-code", + "../platforms/codex", + "../platforms/cursor", + "../platforms/antigravity", + "../platforms/opencode", + "../platforms/pi", + "../extensions/hooks", + "../extensions/mcp" + ], + "entryPointStrategy": "packages", + "packageOptions": { + "entryPoints": ["src/index.ts"], + "excludePrivate": true, + "excludeProtected": true, + "excludeInternal": true, + "excludeExternals": false, + "validation": { + "notExported": false, + "invalidLink": true, + "notDocumented": false + } + }, + "plugin": [ + "typedoc-plugin-markdown", + "typedoc-vitepress-theme" + ], + "theme": "markdown", + "out": "api", + "docsRoot": ".", + "readme": "none", + "cleanOutputDir": true, + "hideGenerator": true, + "githubPages": false, + "treatWarningsAsErrors": true, + "sidebar": { + "pretty": true, + "collapsed": true + } +} diff --git a/packages/extensions/hooks/CHANGELOG.md b/packages/extensions/hooks/CHANGELOG.md new file mode 100644 index 0000000..ec7801c --- /dev/null +++ b/packages/extensions/hooks/CHANGELOG.md @@ -0,0 +1,18 @@ +# @tokenroll/acplugin-extension-hooks + +## 0.0.3-beta + +### Patch Changes + +- Updated dependencies + - @tokenroll/acplugin@0.0.3-beta + +## 0.0.2-beta + +### Major Changes + +- 889da32: Rewrite the Hooks Extension around Core-owned portable-node compilation, one shared Built Handler state, SDK-only Platform Contributors, deterministic target protocol adapters, and Core-managed Asset and third-party license delivery. + +### Patch Changes + +- Updated peer dependency on `@tokenroll/acplugin` to `^0.0.2-beta`. diff --git a/packages/extensions/hooks/LICENSE b/packages/extensions/hooks/LICENSE new file mode 100644 index 0000000..9113de7 --- /dev/null +++ b/packages/extensions/hooks/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 TokenRollAI + +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/packages/extensions/hooks/README.md b/packages/extensions/hooks/README.md new file mode 100644 index 0000000..4449f50 --- /dev/null +++ b/packages/extensions/hooks/README.md @@ -0,0 +1,91 @@ +# @tokenroll/acplugin-extension-hooks + +Portable Hook authoring plus six official Platform Contributors for `@tokenroll/acplugin`. + +Requires Node.js `^20.19.0 || ^22.13.0 || >=23.5.0`. + +`统一书写 Hook,通过 Core Compiler 只构建一次,再由官方 Contributor 交付为六个平台各自支持的静态或运行时能力。` + +```bash +pnpm add -D @tokenroll/acplugin \ + @tokenroll/acplugin-platform-claude-code \ + @tokenroll/acplugin-extension-hooks +``` + +```ts +// acplugin.config.ts +import { defineConfig } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import hooks from '@tokenroll/acplugin-extension-hooks'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [claudeCode()], + extensions: [hooks()], +}); +``` + +Each Hook is a plain ESM default export at `src/hooks//hook.ts`: + +`每个 Hook 使用独立一级目录,并通过 satisfies 获得事件级输入和结果类型。` + +```ts +import type { Hook } from '@tokenroll/acplugin-extension-hooks'; + +export default { + event: 'PreToolUse', + matcher: 'Bash|Write|Edit', + timeout: 10, + platforms: { + codex: { additionalContextLimit: 2_500 }, + }, + async run(input, context) { + return input.toolName === 'Bash' + ? { decision: 'allow' } + : { decision: 'deny', reason: `Denied on ${context.platform}.` }; + }, +} satisfies Hook<'PreToolUse'>; +``` + +The canonical events are `SessionStart`, `SessionEnd`, `UserPromptSubmit`, `PreToolUse`, `PermissionRequest`, `PostToolUse`, `PreCompact`, `PostCompact`, `SubagentStart`, `SubagentStop`, and `Stop`. + +Platform-only events stay explicitly scoped and never expand that union: + +`平台专属事件必须显式限定;其他 Platform 不会获得产物或兼容性结论。` + +```ts +import type { Hook } from '@tokenroll/acplugin-extension-hooks'; + +export default { + event: { platform: 'claude-code', name: 'Setup' }, + matcher: 'init', + run() {}, +} satisfies Hook; +``` + +ACPlugin's Core `portable-node` Compiler bundles each implementation once as a self-contained, platform-neutral Node 20 ESM `hooks//handler.mjs`. Verified wire profiles are compiled into the same file and own native stdin schemas, camelCase conversion, runtime root/data environment mapping, and stdout mapping. The Handler validates event-specific results, keeps stdin/stdout within 1 MiB, emits only stable error codes, and requires no adjacent runtime JavaScript. Third-party code included in a Handler receives a deterministic `THIRD_PARTY_LICENSES.txt`. + +`作者不能声明原始 shell、绝对 executable、HTTP、prompt、agent 或 MCP-tool Handler;平台 wire 协议完全由对应 Contributor 管理。` + +Claude Code uses shell-free exec form (`command: "node"` plus `args`). Codex currently receives a fixed framework-generated command string because its public Hook schema does not expose `args`. A meaningful matcher is reported as `degraded` whenever the selected host silently ignores it, including Claude Code `UserPromptSubmit`/`Stop` and Codex `UserPromptSubmit`/`Stop`; empty Hooks produce no Assets. + +Portable event support: + +| Platform | Native | Transformed | Degraded | Unsupported | +| --- | --- | --- | --- | --- | +| Claude Code | all 11 portable events | — | matcher on selected events | — | +| Codex | all 11 portable events | — | matcher on selected events | — | +| Cursor | — | 9 events | field-level matcher/status loss | `PermissionRequest`, `PostCompact` | +| Antigravity | `SessionStart`, `SessionEnd`, `PreToolUse`, `PostToolUse`, `PreCompact` | — | field-level matcher/status loss | remaining 6 events | +| OpenCode | `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PostCompact` | — | `SessionEnd`, `Stop` | remaining 4 events | +| Pi | `SessionStart`, `SessionEnd`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PreCompact`, `PostCompact` | — | `Stop` | `PermissionRequest`, `SubagentStart`, `SubagentStop` | + +Strict mode rejects degraded or unsupported outcomes; relaxed mode emits only verified runtimes and preserves the full structured report. Empty Hooks produce no Asset. + +Contracts were last rechecked on 2026-08-06 against [Claude Code Hooks](https://code.claude.com/docs/en/hooks), [Codex Hooks](https://learn.chatgpt.com/docs/hooks), [Cursor Hooks](https://cursor.com/docs/agent/hooks), [Antigravity Plugins](https://antigravity.google/docs/plugins?app=cli), [OpenCode Plugins](https://opencode.ai/docs/plugins/), and [Pi Extensions](https://pi.dev/docs/latest/extensions). + +## License + +MIT diff --git a/packages/extensions/hooks/package.json b/packages/extensions/hooks/package.json new file mode 100644 index 0000000..bbc910a --- /dev/null +++ b/packages/extensions/hooks/package.json @@ -0,0 +1,29 @@ +{ + "name": "@tokenroll/acplugin-extension-hooks", + "version": "0.0.3-beta", + "description": "Portable hook authoring and platform contributors for acplugin.", + "type": "module", + "license": "MIT", + "homepage": "https://github.com/TokenRollAI/acplugin#hooks-extension", + "repository": { "type": "git", "url": "git+https://github.com/TokenRollAI/acplugin.git", "directory": "packages/extensions/hooks" }, + "bugs": { "url": "https://github.com/TokenRollAI/acplugin/issues" }, + "sideEffects": false, + "engines": { "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, + "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" } }, + "files": ["dist", "README.md", "LICENSE"], + "publishConfig": { "access": "public" }, + "scripts": { + "build": "tsdown", + "pretest": "pnpm --filter @acplugin/core run build && pnpm --filter @tokenroll/acplugin run build && pnpm --filter @tokenroll/acplugin-platform-claude-code run build && pnpm --filter @tokenroll/acplugin-platform-codex run build && pnpm run build", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc -p tsconfig.json" + }, + "peerDependencies": { "@tokenroll/acplugin": "workspace:^" }, + "devDependencies": { + "@tokenroll/acplugin": "workspace:^", + "@types/node": "catalog:", + "tsdown": "catalog:", + "@typescript/native": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/extensions/hooks/src/build.ts b/packages/extensions/hooks/src/build.ts new file mode 100644 index 0000000..874e64a --- /dev/null +++ b/packages/extensions/hooks/src/build.ts @@ -0,0 +1,66 @@ +import type { + ExtensionBuildContext, + GeneratedAssetRef, + PortableNodeCompileOptions, +} from '@tokenroll/acplugin/sdk'; +import type { HookDescriptorData, ValidatedHooks } from './discovery.js'; +import { createRunnerSource } from './runtime/runner.js'; +import { createWireSource } from './runtime/wire.js'; + +/** portable Handler 内联官方平台协议的稳定虚拟模块。 */ +const HOOK_WIRE_MODULE_ID = 'acplugin:hook-wire'; + +/** 单个 Hook 一次编译后可被全部 Contributor 复用的 State。 */ +export interface BuiltHook { + readonly id: string; + readonly definition: HookDescriptorData; + readonly handler: GeneratedAssetRef; + readonly licenses?: GeneratedAssetRef; +} + +/** Hooks Extension 的无函数 Built State。 */ +export interface BuiltHooks { + readonly hooks: readonly BuiltHook[]; +} + +/** 通过 Core `portable-node` 为每个 Hook 生成一次自包含 Handler。 */ +export async function buildHooks( + context: ExtensionBuildContext, + validated: Readonly, + compile?: PortableNodeCompileOptions, +): Promise { + /** 虚拟 entries 各自从其 Hook 目录解析原始 hook.ts。 */ + const entries = Object.fromEntries(validated.hooks.map(hook => [hook.id, Object.freeze({ + type: 'virtual' as const, + code: createRunnerSource(), + resolveFrom: hook.directory, + mode: 0o755 as const, + })])); + /** result 只包含 Core 签发的 GeneratedAssetRef 和脱敏模块图。 */ + const result = await context.compiler.compile({ + id: 'hooks', + profile: 'portable-node', + entries: Object.freeze(entries), + sourceScopes: Object.freeze([validated.root]), + virtualModules: Object.freeze({ [HOOK_WIRE_MODULE_ID]: createWireSource() }), + ...(compile === undefined ? {} : { options: compile }), + }); + /** built 逐 entry 校验固定 main/license 输出闭包。 */ + const built = validated.hooks.map((hook): BuiltHook => { + /** outputs 是当前 Hook 独立 Bundle 的全部受管文件。 */ + const outputs = result.outputs.filter(output => output.outputId === hook.id); + /** main 必须是 portable-node 固定的唯一可执行入口。 */ + const mains = outputs.filter(output => output.type === 'chunk' && output.fileName === 'main.mjs' && output.isEntry); + /** licenses 只在实际打入第三方依赖时出现。 */ + const licenses = outputs.filter(output => output.type === 'licenses' && output.fileName === 'THIRD_PARTY_LICENSES.txt'); + if (mains.length !== 1 || licenses.length > 1 || outputs.length !== mains.length + licenses.length) + throw new Error(`Compiler returned an invalid Hook output set for "${hook.id}".`); + return Object.freeze({ + id: hook.id, + definition: hook.definition, + handler: mains[0]!.asset, + ...(licenses[0] === undefined ? {} : { licenses: licenses[0].asset }), + }); + }); + return Object.freeze({ hooks: Object.freeze(built) }); +} diff --git a/packages/extensions/hooks/src/constants.ts b/packages/extensions/hooks/src/constants.ts new file mode 100644 index 0000000..59cba67 --- /dev/null +++ b/packages/extensions/hooks/src/constants.ts @@ -0,0 +1,29 @@ +/** Hooks Extension 的稳定包名、配置名和诊断身份。 */ +export const EXTENSION_NAME = '@tokenroll/acplugin-extension-hooks'; + +/** Claude Code 官方 Platform 的稳定 ID。 */ +export const CLAUDE_CODE_PLATFORM_ID = 'claude-code'; + +/** Codex 官方 Platform 的稳定 ID。 */ +export const CODEX_PLATFORM_ID = 'codex'; + +/** Cursor 官方 Platform 的稳定 ID。 */ +export const CURSOR_PLATFORM_ID = 'cursor'; + +/** Antigravity 官方 Platform 的稳定 ID。 */ +export const ANTIGRAVITY_PLATFORM_ID = 'antigravity'; + +/** OpenCode 官方 Platform 的稳定 ID。 */ +export const OPENCODE_PLATFORM_ID = 'opencode'; + +/** Pi 官方 Platform 的稳定 ID。 */ +export const PI_PLATFORM_ID = 'pi'; + +/** Hook 一级目录接受的小写 kebab-case 格式。 */ +export const HOOK_ID_PATTERN: RegExp = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** Platform ID 接受的小写 kebab-case 格式。 */ +export const PLATFORM_ID_PATTERN: RegExp = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** 单个 Handler 接受和输出的最大 JSON 字节数。 */ +export const MAX_HOOK_IO_BYTES: number = 1024 * 1024; diff --git a/packages/extensions/hooks/src/contributors/antigravity.ts b/packages/extensions/hooks/src/contributors/antigravity.ts new file mode 100644 index 0000000..26e1d08 --- /dev/null +++ b/packages/extensions/hooks/src/contributors/antigravity.ts @@ -0,0 +1,69 @@ +import type { ContributionContext, JsonValue, PlatformContributor } from '@tokenroll/acplugin/sdk'; +import type { BuiltHooks } from '../build.js'; +import { eventName } from '../discovery.js'; +import { + addHookRuntime, + addJsonAsset, + appliesToPlatform, + collector, + finishContribution, + reportSupport, + resolveOptions, + type HookEventSupport, +} from './common.js'; + +/** Antigravity 对 canonical Hook 事件的固定映射。 */ +const EVENTS: Readonly> = Object.freeze({ + SessionStart: { supported: true, level: 'native', nativeEvent: 'SessionStart', reason: 'Antigravity supports SessionStart.' }, + SessionEnd: { supported: true, level: 'native', nativeEvent: 'SessionEnd', reason: 'Antigravity supports SessionEnd.' }, + UserPromptSubmit: { supported: false, level: 'unsupported', reason: 'Antigravity has no documented prompt-submit Hook.' }, + PreToolUse: { supported: true, level: 'native', nativeEvent: 'PreToolUse', reason: 'Antigravity supports PreToolUse.' }, + PermissionRequest: { supported: false, level: 'unsupported', reason: 'Antigravity has no distinct permission request Hook.' }, + PostToolUse: { supported: true, level: 'native', nativeEvent: 'PostToolUse', reason: 'Antigravity supports PostToolUse.' }, + PreCompact: { supported: true, level: 'native', nativeEvent: 'PreCompact', reason: 'Antigravity supports PreCompact.' }, + PostCompact: { supported: false, level: 'unsupported', reason: 'Antigravity has no documented post-compaction Hook.' }, + SubagentStart: { supported: false, level: 'unsupported', reason: 'Antigravity has no documented subagent-start Hook.' }, + SubagentStop: { supported: false, level: 'unsupported', reason: 'Antigravity has no documented subagent-stop Hook.' }, + Stop: { supported: false, level: 'unsupported', reason: 'Antigravity has no documented stop Hook.' }, +}); + +/** Antigravity Contributor 只追加根 hooks.json 和支持事件的 Handler。 */ +export const antigravityContributor: PlatformContributor = Object.freeze({ + platform: 'antigravity', + platformApiVersion: '1', + /** 以只读 Built State 追加 Antigravity 的 Hook 文档和兼容性。 */ + async contribute(context: ContributionContext, built: Readonly) { + /** output 汇聚根 Asset 和完整 compatibility。 */ + const output = collector(); + /** groups 使用 Antigravity matcher + hooks 结构。 */ + const groups: Record>[] }[]> = {}; + for (const hook of built.hooks) { + /** event 选择固定支持项。 */ + const event = eventName(hook.definition); + /** support 对平台限定事件不伪造 fallback。 */ + const support = appliesToPlatform(hook, 'antigravity') + ? EVENTS[event] ?? { supported: false, level: 'unsupported' as const, reason: `Antigravity does not recognize ${event}.` } + : { supported: false, level: 'unsupported' as const, reason: 'The Hook explicitly targets another Platform.' }; + /** options 已由共享 validate Schema 约束。 */ + const options = resolveOptions(hook, 'antigravity'); + reportSupport(output, hook, 'antigravity', support, options, { + matcherNative: event === 'PreToolUse' || event === 'PostToolUse', statusNative: false, + }); + if (!support.supported || support.nativeEvent === undefined) + continue; + addHookRuntime(output, hook, 'hooks'); + /** 固定命令只调用当前 Plugin 内 Handler。 */ + const handler = Object.freeze({ + type: 'command', + command: `node "\${ANTIGRAVITY_PLUGIN_ROOT}/hooks/${hook.id}/handler.mjs" antigravity`, + }); + (groups[support.nativeEvent] ??= []).push(Object.freeze({ + ...(options.matcher === undefined ? {} : { matcher: options.matcher }), + hooks: Object.freeze([handler]), + })); + } + if (Object.keys(groups).length > 0) + await addJsonAsset(context, output, 'hooks.json', { hooks: groups } as unknown as JsonValue, built.hooks.map(hook => `hook:${hook.id}`)); + return finishContribution(output); + }, +}); diff --git a/packages/extensions/hooks/src/contributors/claude-code.ts b/packages/extensions/hooks/src/contributors/claude-code.ts new file mode 100644 index 0000000..3ee0926 --- /dev/null +++ b/packages/extensions/hooks/src/contributors/claude-code.ts @@ -0,0 +1,78 @@ +import type { ContributionContext, JsonValue, PlatformContributor } from '@tokenroll/acplugin/sdk'; +import type { BuiltHooks } from '../build.js'; +import { eventName } from '../discovery.js'; +import { + addHookRuntime, + addJsonAsset, + appliesToPlatform, + collector, + finishContribution, + hasExtensionPoint, + reportSupport, + resolveOptions, +} from './common.js'; + +/** Claude Code matcher 字段存在但当前事件会忽略 matcher 的集合。 */ +const MATCHER_IGNORED = new Set([ + 'UserPromptSubmit', 'PostToolBatch', 'Stop', 'TeammateIdle', 'TaskCreated', 'TaskCompleted', + 'WorktreeCreate', 'WorktreeRemove', 'MessageDisplay', 'CwdChanged', +]); + +/** Claude Code Hook Manifest 中的一组受控 command Handler。 */ +interface HookGroup { + readonly matcher?: string; + readonly hooks: readonly Readonly>[]; +} + +/** Claude Code Contributor 使用 Plugin Manifest 的 hooks exact point。 */ +export const claudeCodeContributor: PlatformContributor = Object.freeze({ + platform: 'claude-code', + platformApiVersion: '1', + /** 生成原生 exec-form hooks.json 并复用 portable Handler refs。 */ + async contribute(context: ContributionContext, built: Readonly) { + /** output 同时收集 add-only Assets 和兼容性。 */ + const output = collector(); + /** groups 按事件保存,stableJson 最终固定键序。 */ + const groups: Record = {}; + for (const hook of built.hooks) { + /** event 是 descriptor 中已验证的原生或规范事件。 */ + const event = eventName(hook.definition); + /** applicable false 只报告 unsupported,不能引用 Handler。 */ + const applicable = appliesToPlatform(hook, 'claude-code'); + /** options 已在 validate 阶段通过 Claude Schema。 */ + const options = resolveOptions(hook, 'claude-code'); + reportSupport(output, hook, 'claude-code', applicable + ? { supported: true, level: 'native', reason: `Claude Code supports local command handlers for ${event}.` } + : { supported: false, level: 'unsupported', reason: 'The Hook explicitly targets another Platform.' }, options, { + matcherNative: !MATCHER_IGNORED.has(event), + statusNative: true, + }); + if (!applicable) + continue; + addHookRuntime(output, hook, 'hooks'); + /** handler 只调用受管 Bundle,不接受作者命令。 */ + const handler: Readonly> = Object.freeze({ + type: 'command', + command: 'node', + args: [`\${CLAUDE_PLUGIN_ROOT}/hooks/${hook.id}/handler.mjs`, 'claude-code'], + ...(options.timeout === undefined ? {} : { timeout: options.timeout }), + ...(options.statusMessage === undefined ? {} : { statusMessage: options.statusMessage }), + }); + (groups[event] ??= []).push(Object.freeze({ + ...(options.matcher === undefined ? {} : { matcher: options.matcher }), + hooks: Object.freeze([handler]), + })); + } + if (Object.keys(groups).length === 0) + return finishContribution(output); + if (!hasExtensionPoint(context, 'plugin-manifest', ['hooks'])) { + context.diagnostics.report({ + code: 'HOOK_PLATFORM_DOCUMENT_MISSING', severity: 'error', + message: 'Claude Code Platform does not expose plugin-manifest hooks.', + }); + return finishContribution(output); + } + await addJsonAsset(context, output, 'hooks/hooks.json', { hooks: groups } as unknown as JsonValue, built.hooks.map(hook => `hook:${hook.id}`)); + return finishContribution(output, [{ document: 'plugin-manifest', path: ['hooks'], value: './hooks/hooks.json' }]); + }, +}); diff --git a/packages/extensions/hooks/src/contributors/codex.ts b/packages/extensions/hooks/src/contributors/codex.ts new file mode 100644 index 0000000..1398e69 --- /dev/null +++ b/packages/extensions/hooks/src/contributors/codex.ts @@ -0,0 +1,69 @@ +import type { ContributionContext, JsonValue, PlatformContributor } from '@tokenroll/acplugin/sdk'; +import type { BuiltHooks } from '../build.js'; +import { eventName } from '../discovery.js'; +import { + addHookRuntime, + addJsonAsset, + appliesToPlatform, + collector, + finishContribution, + hasExtensionPoint, + reportSupport, + resolveOptions, +} from './common.js'; + +/** Codex Hook Manifest 中的一组 command Handler。 */ +interface HookGroup { + readonly matcher?: string; + readonly hooks: readonly Readonly>[]; +} + +/** Codex Contributor 生成当前字符串命令协议。 */ +export const codexContributor: PlatformContributor = Object.freeze({ + platform: 'codex', + platformApiVersion: '1', + /** 以只读 Built State 追加 Codex 的 Hook 文档和兼容性。 */ + async contribute(context: ContributionContext, built: Readonly) { + /** output 汇聚同 owner 的配置、Handler 与兼容性。 */ + const output = collector(); + /** groups 按规范事件名建立。 */ + const groups: Record = {}; + for (const hook of built.hooks) { + /** event 经过 Extension validate。 */ + const event = eventName(hook.definition); + /** applicable 区分规范事件和 Claude-only 事件。 */ + const applicable = appliesToPlatform(hook, 'codex'); + /** options 是 Codex 精确覆盖结果。 */ + const options = resolveOptions(hook, 'codex'); + reportSupport(output, hook, 'codex', applicable + ? { supported: true, level: 'native', reason: `Codex supports local command handlers for ${event}.` } + : { supported: false, level: 'unsupported', reason: 'The Hook explicitly targets another Platform.' }, options, { + matcherNative: event !== 'UserPromptSubmit' && event !== 'Stop', + statusNative: true, + }); + if (!applicable) + continue; + addHookRuntime(output, hook, 'hooks'); + /** Codex 当前 command 字段使用固定 Plugin root 模板。 */ + const handler: Readonly> = Object.freeze({ + type: 'command', + command: `node "\${PLUGIN_ROOT}/hooks/${hook.id}/handler.mjs" codex`, + ...(options.timeout === undefined ? {} : { timeout: options.timeout }), + ...(options.statusMessage === undefined ? {} : { statusMessage: options.statusMessage }), + ...(options.additionalContextLimit === undefined ? {} : { additionalContextLimit: options.additionalContextLimit }), + }); + (groups[event] ??= []).push(Object.freeze({ + ...(options.matcher === undefined ? {} : { matcher: options.matcher }), + hooks: Object.freeze([handler]), + })); + } + if (Object.keys(groups).length === 0) + return finishContribution(output); + if (!hasExtensionPoint(context, 'plugin-manifest', ['hooks'])) { + context.diagnostics.report({ code: 'HOOK_PLATFORM_DOCUMENT_MISSING', severity: 'error', message: 'Codex Platform does not expose plugin-manifest hooks.' }); + return finishContribution(output); + } + await addJsonAsset(context, output, 'hooks/hooks.json', { hooks: groups } as unknown as JsonValue, built.hooks.map(hook => `hook:${hook.id}`)); + return finishContribution(output, [{ document: 'plugin-manifest', path: ['hooks'], value: './hooks/hooks.json' }]); + }, +}); diff --git a/packages/extensions/hooks/src/contributors/common.ts b/packages/extensions/hooks/src/contributors/common.ts new file mode 100644 index 0000000..da575a6 --- /dev/null +++ b/packages/extensions/hooks/src/contributors/common.ts @@ -0,0 +1,191 @@ +import { + stableJson, + type CompatibilityInput, + type ContributionContext, + type DocumentFieldPath, + type JsonValue, + type PackageAssetInput, + type PackageContribution, +} from '@tokenroll/acplugin/sdk'; +import type { BuiltHook, BuiltHooks } from '../build.js'; +import { eventCapability, platformForEvent, platformOptions } from '../discovery.js'; + +/** Contributor 合并顶层默认值与平台覆盖后的配置。 */ +export interface ResolvedHookOptions { + readonly matcher?: string; + readonly timeout?: number; + readonly statusMessage?: string; + readonly additionalContextLimit?: number; +} + +/** 非默认 Platform 对一个规范事件的固定支持结论。 */ +export interface HookEventSupport { + readonly supported: boolean; + readonly level: 'native' | 'transform' | 'degraded' | 'unsupported'; + readonly nativeEvent?: string; + readonly reason: string; +} + +/** Contributor 构建结果时使用的 mutable 收集器。 */ +export interface ContributionCollector { + readonly assets: PackageAssetInput[]; + readonly compatibility: CompatibilityInput[]; +} + +/** @returns Hook 是否声明为当前 Platform 可消费。 */ +export function appliesToPlatform(hook: BuiltHook, platform: string): boolean { + /** target 省略表示规范事件面向全部 Platform。 */ + const target = platformForEvent(hook.definition); + return target === undefined || target === platform; +} + +/** @returns 当前 Platform 合并后的纯数据 Hook options。 */ +export function resolveOptions(hook: BuiltHook, platform: string): ResolvedHookOptions { + /** override 已在 validate 阶段通过对应 Contributor Schema。 */ + const override = platformOptions(hook.definition, platform); + /** 每项只在覆盖类型准确时替换顶层值。 */ + const matcher = typeof override?.matcher === 'string' + ? override.matcher + : typeof hook.definition.matcher === 'string' ? hook.definition.matcher : undefined; + /** timeout 使用相同的显式覆盖优先级。 */ + const timeout = typeof override?.timeout === 'number' + ? override.timeout + : typeof hook.definition.timeout === 'number' ? hook.definition.timeout : undefined; + /** statusMessage 不进入不支持的平台 wire。 */ + const statusMessage = typeof override?.statusMessage === 'string' + ? override.statusMessage + : typeof hook.definition.statusMessage === 'string' ? hook.definition.statusMessage : undefined; + /** additionalContextLimit 只属于 Codex 覆盖。 */ + const additionalContextLimit = typeof override?.additionalContextLimit === 'number' + ? override.additionalContextLimit + : undefined; + return Object.freeze({ + ...(matcher === undefined ? {} : { matcher }), + ...(timeout === undefined ? {} : { timeout }), + ...(statusMessage === undefined ? {} : { statusMessage }), + ...(additionalContextLimit === undefined ? {} : { additionalContextLimit }), + }); +} + +/** @returns matcher 是否实际缩小事件范围。 */ +export function meaningfulMatcher(value: string | undefined): boolean { + return value !== undefined && value !== '' && value !== '*'; +} + +/** @returns base Document 是否公开当前 exact add-only point。 */ +export function hasExtensionPoint( + context: ContributionContext, + documentId: string, + path: DocumentFieldPath, +): boolean { + /** key 采用 JSON tuple,避免字段分隔符歧义。 */ + const key = JSON.stringify(path); + /** document 只来自当前 Platform base snapshot。 */ + const document = context.base.documents.find(candidate => candidate.id === documentId); + return document?.extensionPoints.some(candidate => JSON.stringify(candidate) === key) === true; +} + +/** 对一个支持结论追加事件 tuple 和可选 matcher/status 差异。 */ +export function reportSupport( + collector: ContributionCollector, + hook: BuiltHook, + platform: string, + support: HookEventSupport, + options: ResolvedHookOptions, + input: { readonly matcherNative: boolean; readonly statusNative: boolean }, +): void { + collector.compatibility.push(Object.freeze({ + subject: `hook:${hook.id}`, + capability: `event.${eventCapability(hook.definition)}`, + level: support.level, + ...(support.nativeEvent === undefined ? {} : { transformation: support.nativeEvent.toLowerCase().replaceAll('_', '-') }), + reason: support.reason, + })); + if (!support.supported) + return; + if (meaningfulMatcher(options.matcher) && !input.matcherNative) { + collector.compatibility.push(Object.freeze({ + subject: `hook:${hook.id}`, + capability: 'matcher', + level: 'degraded', + reason: `${platform} cannot preserve this matcher for the selected event.`, + })); + } + if (options.statusMessage !== undefined && !input.statusNative) { + collector.compatibility.push(Object.freeze({ + subject: `hook:${hook.id}`, + capability: 'status-message', + level: 'degraded', + reason: `${platform} has no stable Hook status message field in this protocol.`, + })); + } +} + +/** 把同一个 Core GeneratedAssetRef 映射到当前 Platform 固定 Handler 根。 */ +export function addHookRuntime( + collector: ContributionCollector, + hook: BuiltHook, + root: string, +): void { + collector.assets.push(Object.freeze({ path: `${root}/${hook.id}/handler.mjs`, asset: hook.handler })); + if (hook.licenses !== undefined) { + collector.assets.push(Object.freeze({ + path: `${root}/${hook.id}/THIRD_PARTY_LICENSES.txt`, + asset: hook.licenses, + })); + } +} + +/** 通过 Extension owner Asset Service 创建稳定 JSON Package Asset。 */ +export async function addJsonAsset( + context: ContributionContext, + collector: ContributionCollector, + path: string, + value: JsonValue, + subjects: readonly string[], +): Promise { + /** asset bytes 来自 SDK stable codec,不写 dist/workDir。 */ + const asset = await context.assets.fromBytes({ + bytes: stableJson(value), + origin: { operation: 'hook-platform-config', subjects }, + }); + collector.assets.push(Object.freeze({ path, asset })); +} + +/** 通过 Extension owner Asset Service 创建固定运行时桥接 Asset。 */ +export async function addRuntimeAsset( + context: ContributionContext, + collector: ContributionCollector, + path: string, + bytes: string, + subjects: readonly string[], +): Promise { + /** 运行时桥只包含 Extension 自有代码和静态 descriptor。 */ + const asset = await context.assets.fromBytes({ + bytes, + origin: { operation: 'hook-platform-runtime', subjects }, + }); + collector.assets.push(Object.freeze({ path, asset })); +} + +/** @returns 冻结且满足 Contribution 必填 compatibility 的最终对象。 */ +export function finishContribution( + collector: ContributionCollector, + documentFields: PackageContribution['documentFields'] = [], +): PackageContribution { + return Object.freeze({ + ...(documentFields.length === 0 ? {} : { documentFields: Object.freeze([...documentFields]) }), + ...(collector.assets.length === 0 ? {} : { assets: Object.freeze(collector.assets) }), + compatibility: Object.freeze(collector.compatibility), + }); +} + +/** 创建一个空的 Contributor 收集器。 */ +export function collector(): ContributionCollector { + return { assets: [], compatibility: [] }; +} + +/** @returns 所有 Hook 的稳定 subject 列表。 */ +export function hookSubjects(built: Readonly): readonly string[] { + return Object.freeze(built.hooks.map(hook => `hook:${hook.id}`)); +} diff --git a/packages/extensions/hooks/src/contributors/cursor.ts b/packages/extensions/hooks/src/contributors/cursor.ts new file mode 100644 index 0000000..183a0b7 --- /dev/null +++ b/packages/extensions/hooks/src/contributors/cursor.ts @@ -0,0 +1,70 @@ +import type { ContributionContext, JsonValue, PlatformContributor } from '@tokenroll/acplugin/sdk'; +import type { BuiltHooks } from '../build.js'; +import { eventName } from '../discovery.js'; +import { + addHookRuntime, + addJsonAsset, + appliesToPlatform, + collector, + finishContribution, + hasExtensionPoint, + reportSupport, + resolveOptions, + type HookEventSupport, +} from './common.js'; + +/** Cursor 对 canonical Hook 事件的固定映射。 */ +const EVENTS: Readonly> = Object.freeze({ + SessionStart: { supported: true, level: 'transform', nativeEvent: 'sessionStart', reason: 'Cursor provides sessionStart.' }, + SessionEnd: { supported: true, level: 'transform', nativeEvent: 'sessionEnd', reason: 'Cursor provides sessionEnd.' }, + UserPromptSubmit: { supported: true, level: 'transform', nativeEvent: 'beforeSubmitPrompt', reason: 'Cursor provides beforeSubmitPrompt.' }, + PreToolUse: { supported: true, level: 'transform', nativeEvent: 'preToolUse', reason: 'Cursor provides preToolUse.' }, + PermissionRequest: { supported: false, level: 'unsupported', reason: 'Cursor has no verified distinct permission request Hook.' }, + PostToolUse: { supported: true, level: 'transform', nativeEvent: 'postToolUse', reason: 'Cursor provides postToolUse.' }, + PreCompact: { supported: true, level: 'transform', nativeEvent: 'preCompact', reason: 'Cursor provides preCompact.' }, + PostCompact: { supported: false, level: 'unsupported', reason: 'Cursor has no verified post-compaction Hook.' }, + SubagentStart: { supported: true, level: 'transform', nativeEvent: 'subagentStart', reason: 'Cursor provides subagentStart.' }, + SubagentStop: { supported: true, level: 'transform', nativeEvent: 'subagentStop', reason: 'Cursor provides subagentStop.' }, + Stop: { supported: true, level: 'transform', nativeEvent: 'stop', reason: 'Cursor provides stop.' }, +}); + +/** Cursor Contributor 生成 version 1 command Hooks。 */ +export const cursorContributor: PlatformContributor = Object.freeze({ + platform: 'cursor', + platformApiVersion: '1', + /** 以只读 Built State 追加 Cursor 的 Hook 文档和兼容性。 */ + async contribute(context: ContributionContext, built: Readonly) { + /** output 汇聚 Cursor 自有 add-only 结果。 */ + const output = collector(); + /** groups 映射 Cursor 原生事件到命令数组。 */ + const groups: Record>[]> = {}; + for (const hook of built.hooks) { + /** event 决定固定支持矩阵。 */ + const event = eventName(hook.definition); + /** support 对平台限定事件显式 unsupported。 */ + const support = appliesToPlatform(hook, 'cursor') + ? EVENTS[event] ?? { supported: false, level: 'unsupported' as const, reason: `Cursor does not recognize ${event}.` } + : { supported: false, level: 'unsupported' as const, reason: 'The Hook explicitly targets another Platform.' }; + /** options 用于 matcher/status 兼容结论。 */ + const options = resolveOptions(hook, 'cursor'); + reportSupport(output, hook, 'cursor', support, options, { + matcherNative: event === 'PreToolUse' || event === 'PostToolUse', + statusNative: false, + }); + if (!support.supported || support.nativeEvent === undefined) + continue; + addHookRuntime(output, hook, 'hooks'); + /** command 只引用 Cursor Plugin root 下的受管 Handler。 */ + const command = `node "\${CURSOR_PLUGIN_ROOT}/hooks/${hook.id}/handler.mjs" cursor`; + (groups[support.nativeEvent] ??= []).push(Object.freeze({ command })); + } + if (Object.keys(groups).length === 0) + return finishContribution(output); + if (!hasExtensionPoint(context, 'plugin-manifest', ['hooks'])) { + context.diagnostics.report({ code: 'HOOK_PLATFORM_DOCUMENT_MISSING', severity: 'error', message: 'Cursor Platform does not expose plugin-manifest hooks.' }); + return finishContribution(output); + } + await addJsonAsset(context, output, 'hooks/hooks.json', { version: 1, hooks: groups } as unknown as JsonValue, built.hooks.map(hook => `hook:${hook.id}`)); + return finishContribution(output, [{ document: 'plugin-manifest', path: ['hooks'], value: './hooks/hooks.json' }]); + }, +}); diff --git a/packages/extensions/hooks/src/contributors/index.ts b/packages/extensions/hooks/src/contributors/index.ts new file mode 100644 index 0000000..a861901 --- /dev/null +++ b/packages/extensions/hooks/src/contributors/index.ts @@ -0,0 +1,20 @@ +import type { PlatformContributor } from '@tokenroll/acplugin/sdk'; +import type { BuiltHooks } from '../build.js'; +import { antigravityContributor } from './antigravity.js'; +import { claudeCodeContributor } from './claude-code.js'; +import { codexContributor } from './codex.js'; +import { cursorContributor } from './cursor.js'; +import { openCodeContributor } from './opencode.js'; +import { piContributor } from './pi.js'; + +/** @returns 六个互不观察、只消费同一 Built State 的官方 Contributors。 */ +export function createHooksContributors(): readonly PlatformContributor[] { + return Object.freeze([ + claudeCodeContributor, + codexContributor, + cursorContributor, + antigravityContributor, + openCodeContributor, + piContributor, + ]); +} diff --git a/packages/extensions/hooks/src/contributors/opencode.ts b/packages/extensions/hooks/src/contributors/opencode.ts new file mode 100644 index 0000000..0af48f8 --- /dev/null +++ b/packages/extensions/hooks/src/contributors/opencode.ts @@ -0,0 +1,69 @@ +import type { ContributionContext, PlatformContributor } from '@tokenroll/acplugin/sdk'; +import type { BuiltHook, BuiltHooks } from '../build.js'; +import { eventName } from '../discovery.js'; +import { createOpenCodePluginSource, runtimeHookDescriptor } from '../runtime/integration.js'; +import { + addHookRuntime, + addRuntimeAsset, + appliesToPlatform, + collector, + finishContribution, + reportSupport, + resolveOptions, + type HookEventSupport, +} from './common.js'; + +/** OpenCode runtime Plugin 对 canonical 事件的固定映射。 */ +const EVENTS: Readonly> = Object.freeze({ + SessionStart: { supported: true, level: 'native', nativeEvent: 'session.created', reason: 'OpenCode exposes session.created.' }, + SessionEnd: { supported: true, level: 'degraded', nativeEvent: 'session.deleted', reason: 'OpenCode session.deleted cannot preserve every completion result.' }, + UserPromptSubmit: { supported: true, level: 'native', nativeEvent: 'chat.message', reason: 'OpenCode exposes chat.message.' }, + PreToolUse: { supported: true, level: 'native', nativeEvent: 'tool.execute.before', reason: 'OpenCode exposes tool.execute.before.' }, + PermissionRequest: { supported: false, level: 'unsupported', reason: 'OpenCode has no distinct permission request Hook.' }, + PostToolUse: { supported: true, level: 'native', nativeEvent: 'tool.execute.after', reason: 'OpenCode exposes tool.execute.after.' }, + PreCompact: { supported: false, level: 'unsupported', reason: 'OpenCode has no verified pre-compaction Hook.' }, + PostCompact: { supported: true, level: 'native', nativeEvent: 'session.compacted', reason: 'OpenCode exposes session.compacted.' }, + SubagentStart: { supported: false, level: 'unsupported', reason: 'OpenCode has no stable subagent-start Hook.' }, + SubagentStop: { supported: false, level: 'unsupported', reason: 'OpenCode has no stable subagent-stop Hook.' }, + Stop: { supported: true, level: 'degraded', nativeEvent: 'session.idle', reason: 'OpenCode session.idle cannot preserve all stop decisions.' }, +}); + +/** OpenCode Contributor 生成 workspace runtime Plugin。 */ +export const openCodeContributor: PlatformContributor = Object.freeze({ + platform: 'opencode', + platformApiVersion: '1', + /** 以只读 Built State 追加 OpenCode 的 Hook 运行时和兼容性。 */ + async contribute(context: ContributionContext, built: Readonly) { + /** output 汇聚 workspace Assets 与完整 compatibility。 */ + const output = collector(); + /** supported 保存实际会出现在 runtime Plugin 中的 Hook。 */ + const supported: BuiltHook[] = []; + for (const hook of built.hooks) { + /** event 选择固定 runtime event。 */ + const event = eventName(hook.definition); + /** support 对 Claude-only event 返回 unsupported。 */ + const support = appliesToPlatform(hook, 'opencode') + ? EVENTS[event] ?? { supported: false, level: 'unsupported' as const, reason: `OpenCode does not recognize ${event}.` } + : { supported: false, level: 'unsupported' as const, reason: 'The Hook explicitly targets another Platform.' }; + /** options 进入静态 runtime descriptor。 */ + const options = resolveOptions(hook, 'opencode'); + reportSupport(output, hook, 'opencode', support, options, { + matcherNative: event === 'PreToolUse' || event === 'PostToolUse', statusNative: false, + }); + if (!support.supported) + continue; + supported.push(hook); + addHookRuntime(output, hook, '.opencode/acplugin-hooks'); + } + if (supported.length > 0) { + /** descriptors 不包含函数、SourceRef 或物理路径。 */ + const descriptors = supported.map((hook) => { + /** options 决定 matcher 和固定子进程 timeout。 */ + const options = resolveOptions(hook, 'opencode'); + return runtimeHookDescriptor(hook, options.matcher, options.timeout); + }); + await addRuntimeAsset(context, output, '.opencode/plugins/acplugin-hooks.mjs', createOpenCodePluginSource(descriptors), supported.map(hook => `hook:${hook.id}`)); + } + return finishContribution(output); + }, +}); diff --git a/packages/extensions/hooks/src/contributors/pi.ts b/packages/extensions/hooks/src/contributors/pi.ts new file mode 100644 index 0000000..7b0ae45 --- /dev/null +++ b/packages/extensions/hooks/src/contributors/pi.ts @@ -0,0 +1,76 @@ +import type { ContributionContext, PlatformContributor } from '@tokenroll/acplugin/sdk'; +import type { BuiltHook, BuiltHooks } from '../build.js'; +import { eventName } from '../discovery.js'; +import { createPiExtensionSource, runtimeHookDescriptor } from '../runtime/integration.js'; +import { + addHookRuntime, + addRuntimeAsset, + appliesToPlatform, + collector, + finishContribution, + hasExtensionPoint, + reportSupport, + resolveOptions, + type HookEventSupport, +} from './common.js'; + +/** Pi runtime Extension 对 canonical 事件的固定映射。 */ +const EVENTS: Readonly> = Object.freeze({ + SessionStart: { supported: true, level: 'native', nativeEvent: 'session_start', reason: 'Pi exposes session_start.' }, + SessionEnd: { supported: true, level: 'native', nativeEvent: 'session_shutdown', reason: 'Pi exposes session_shutdown.' }, + UserPromptSubmit: { supported: true, level: 'native', nativeEvent: 'input', reason: 'Pi exposes input.' }, + PreToolUse: { supported: true, level: 'native', nativeEvent: 'tool_call', reason: 'Pi exposes tool_call.' }, + PermissionRequest: { supported: false, level: 'unsupported', reason: 'Pi has no distinct permission request event.' }, + PostToolUse: { supported: true, level: 'native', nativeEvent: 'tool_result', reason: 'Pi exposes tool_result.' }, + PreCompact: { supported: true, level: 'native', nativeEvent: 'session_before_compact', reason: 'Pi exposes session_before_compact.' }, + PostCompact: { supported: true, level: 'native', nativeEvent: 'session_compact', reason: 'Pi exposes session_compact.' }, + SubagentStart: { supported: false, level: 'unsupported', reason: 'Pi has no canonical subagent-start event.' }, + SubagentStop: { supported: false, level: 'unsupported', reason: 'Pi has no canonical subagent-stop event.' }, + Stop: { supported: true, level: 'degraded', nativeEvent: 'agent_end', reason: 'Pi agent_end cannot enforce every stop decision.' }, +}); + +/** Pi Contributor 生成一个 npm Package Extension 和 portable Handlers。 */ +export const piContributor: PlatformContributor = Object.freeze({ + platform: 'pi', + platformApiVersion: '1', + /** 以只读 Built State 追加 Pi 的 Hook 运行时和兼容性。 */ + async contribute(context: ContributionContext, built: Readonly) { + /** output 汇聚 Package Assets 与完整 compatibility。 */ + const output = collector(); + /** supported 保存实际注册到 Pi Extension 的 Hook。 */ + const supported: BuiltHook[] = []; + for (const hook of built.hooks) { + /** event 选择 Pi runtime event。 */ + const event = eventName(hook.definition); + /** support 对平台限定事件不伪造交付。 */ + const support = appliesToPlatform(hook, 'pi') + ? EVENTS[event] ?? { supported: false, level: 'unsupported' as const, reason: `Pi does not recognize ${event}.` } + : { supported: false, level: 'unsupported' as const, reason: 'The Hook explicitly targets another Platform.' }; + /** options 进入 runtime descriptor 和 compatibility。 */ + const options = resolveOptions(hook, 'pi'); + reportSupport(output, hook, 'pi', support, options, { + matcherNative: event === 'PreToolUse' || event === 'PostToolUse', statusNative: false, + }); + if (!support.supported) + continue; + supported.push(hook); + addHookRuntime(output, hook, 'extensions/acplugin-hooks'); + } + if (supported.length === 0) + return finishContribution(output); + if (!hasExtensionPoint(context, 'package-manifest', ['pi', 'extensions'])) { + context.diagnostics.report({ code: 'HOOK_PLATFORM_DOCUMENT_MISSING', severity: 'error', message: 'Pi Platform does not expose package-manifest pi.extensions.' }); + return finishContribution(output); + } + /** descriptors 是 Pi Extension 内嵌的纯静态 Hook 数据。 */ + const descriptors = supported.map((hook) => { + /** options 决定 matcher 和子进程 timeout。 */ + const options = resolveOptions(hook, 'pi'); + return runtimeHookDescriptor(hook, options.matcher, options.timeout); + }); + await addRuntimeAsset(context, output, 'extensions/acplugin-hooks.mjs', createPiExtensionSource(descriptors), supported.map(hook => `hook:${hook.id}`)); + return finishContribution(output, [{ + document: 'package-manifest', path: ['pi', 'extensions'], value: ['./extensions/acplugin-hooks.mjs'], + }]); + }, +}); diff --git a/packages/extensions/hooks/src/discovery.ts b/packages/extensions/hooks/src/discovery.ts new file mode 100644 index 0000000..acb3aa5 --- /dev/null +++ b/packages/extensions/hooks/src/discovery.ts @@ -0,0 +1,353 @@ +import { + snapshotJson, + type ExtensionDiscoverContext, + type ExtensionValidateContext, + type JsonValue, + type SourceDirectoryRef, + type SourceFileRef, +} from '@tokenroll/acplugin/sdk'; +import { + CLAUDE_CODE_PLATFORM_ID, + CODEX_PLATFORM_ID, + HOOK_ID_PATTERN, + PLATFORM_ID_PATTERN, +} from './constants.js'; +import { + CLAUDE_CODE_PLATFORM_EVENTS, + HOOK_EVENTS, + type HookEvent, + type HookEventDeclaration, +} from './types.js'; + +/** Hook descriptor 顶层唯一允许的作者字段。 */ +const HOOK_FIELDS = new Set(['event', 'matcher', 'timeout', 'statusMessage', 'platforms', 'run']); + +/** Claude Code 单 Hook 覆盖允许的字段。 */ +const CLAUDE_FIELDS = new Set(['matcher', 'timeout', 'statusMessage']); + +/** Codex 单 Hook 覆盖允许的字段。 */ +const CODEX_FIELDS = new Set(['matcher', 'timeout', 'statusMessage', 'additionalContextLimit']); + +/** 其余官方 Contributor 共同接受的字段。 */ +const PORTABLE_FIELDS = new Set(['matcher', 'timeout', 'statusMessage']); + +/** 拥有固定平台覆盖 Schema 的官方 Platform。 */ +const OFFICIAL_PLATFORMS = new Set(['claude-code', 'codex', 'cursor', 'antigravity', 'opencode', 'pi']); + +/** 规范 Hook 事件的运行时集合。 */ +const EVENT_SET = new Set(HOOK_EVENTS); + +/** Claude Code 平台限定事件的运行时集合。 */ +const CLAUDE_EVENT_SET = new Set(CLAUDE_CODE_PLATFORM_EVENTS); + +/** State 中不包含 `run` 的纯数据 Hook descriptor。 */ +export interface HookDescriptorData { + readonly event: JsonValue; + readonly matcher?: JsonValue; + readonly timeout?: JsonValue; + readonly statusMessage?: JsonValue; + readonly platforms?: JsonValue; + readonly unknownFields: readonly string[]; + readonly runValid: boolean; +} + +/** discover/validate 阶段使用的 owner-bound Hook 来源。 */ +export interface DiscoveredHook { + readonly id: string; + readonly location: string; + readonly directory: SourceDirectoryRef; + readonly source: SourceFileRef; + readonly definition: HookDescriptorData; +} + +/** Hooks Extension 的非空 discovered State。 */ +export interface DiscoveredHooks { + readonly root: SourceDirectoryRef; + readonly hooks: readonly DiscoveredHook[]; +} + +/** validation 通过后仍保持纯数据和 SourceRef 的 State。 */ +export type ValidatedHooks = DiscoveredHooks; + +/** @returns 未知值是否为不带行为的普通对象。 */ +function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) + return false; + /** prototype 用于拒绝 class、Date、Map 等可执行容器。 */ + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +/** 把已加载作者模块转换为不包含 `run` 的 immutable descriptor State。 */ +function descriptorData(value: unknown): HookDescriptorData { + if (!isPlainObject(value) || Object.getOwnPropertySymbols(value).length > 0) + throw new TypeError('Hook descriptor must be a plain object.'); + /** fields 只通过 descriptors 读取,禁止 non-enumerable accessor 隐藏语义。 */ + const fields = Object.getOwnPropertyDescriptors(value); + if (Object.values(fields).some(descriptor => !('value' in descriptor))) + throw new TypeError('Hook descriptor fields must be data properties.'); + /** unknownFields 只保留字段名,未知值不会进入跨阶段 State。 */ + const unknownFields = Object.keys(fields).filter(field => !HOOK_FIELDS.has(field)).sort(); + /** result 主动移除唯一可执行字段 run。 */ + return Object.freeze({ + event: fields.event === undefined || fields.event.value === undefined + ? null + : snapshotJson(fields.event.value, 'Hook.event'), + ...(fields.matcher === undefined || fields.matcher.value === undefined + ? {} + : { matcher: snapshotJson(fields.matcher.value, 'Hook.matcher') }), + ...(fields.timeout === undefined || fields.timeout.value === undefined + ? {} + : { timeout: snapshotJson(fields.timeout.value, 'Hook.timeout') }), + ...(fields.statusMessage === undefined || fields.statusMessage.value === undefined + ? {} + : { statusMessage: snapshotJson(fields.statusMessage.value, 'Hook.statusMessage') }), + ...(fields.platforms === undefined || fields.platforms.value === undefined + ? {} + : { platforms: snapshotJson(fields.platforms.value, 'Hook.platforms') }), + unknownFields: Object.freeze(unknownFields), + runValid: typeof fields.run?.value === 'function', + }); +} + +/** 发现、加载并去函数化 `src/hooks//hook.ts`。 */ +export async function discoverHooks( + context: ExtensionDiscoverContext, + include?: ReadonlySet, +): Promise { + /** hooks 是 Resource Registry 为当前 Extension 独占签发的根。 */ + const root = context.roots.hooks; + if (root === undefined) + return undefined; + /** 顶层 entries 已经过 Source Registry 的 symlink/special/collision 审计。 */ + const entries = await context.sources.list(root); + /** hooks 只保存成功加载且被 include 选中的 descriptor。 */ + const hooks: DiscoveredHook[] = []; + /** found 用于精确报告 include 中不存在的资源。 */ + const found = new Set(); + for (const entry of entries) { + if (entry.type !== 'directory' || !HOOK_ID_PATTERN.test(entry.name)) { + context.diagnostics.report({ + code: 'HOOK_ENTRY_INVALID', + severity: 'error', + message: 'Hook entries must be one-level lowercase kebab-case directories.', + location: { path: entry.path }, + }); + continue; + } + if (include !== undefined && !include.has(entry.name)) + continue; + found.add(entry.name); + try { + /** source 是作者格式唯一入口;同目录依赖由 Module/Compiler Host 图审计。 */ + const source = await context.sources.file(entry.directory, 'hook.ts'); + /** raw 只在当前调用栈内存在,run 不进入返回 State。 */ + const raw = await context.modules.loadDefault({ id: `hook-${entry.name}`, entry: source }); + hooks.push(Object.freeze({ + id: entry.name, + location: source.path, + directory: entry.directory, + source, + definition: descriptorData(raw), + })); + } catch { + context.diagnostics.report({ + code: 'HOOK_LOAD_FAILED', + severity: 'error', + message: `Hook "${entry.name}" must provide a safe plain default-exported descriptor in hook.ts.`, + location: { path: `${entry.path}/hook.ts` }, + }); + } + } + if (include !== undefined) { + for (const id of include) { + if (!found.has(id)) { + context.diagnostics.report({ + code: 'HOOK_INCLUDE_MISSING', + severity: 'error', + message: `Included Hook "${id}" does not exist under src/hooks.`, + location: { path: `${root.path}/${id}` }, + }); + } + } + } + return hooks.length === 0 ? undefined : Object.freeze({ root, hooks: Object.freeze(hooks) }); +} + +/** 提交一个绑定 Hook 来源和字段的 validate 诊断。 */ +function error( + context: ExtensionValidateContext, + hook: DiscoveredHook, + code: string, + message: string, + fieldPath?: readonly (string | number)[], +): void { + context.diagnostics.report({ + code, + severity: 'error', + message, + location: { path: hook.location }, + ...(fieldPath === undefined ? {} : { fieldPath }), + }); +} + +/** 校验 matcher 语法与稳定字符串类型。 */ +function validateMatcher(context: ExtensionValidateContext, hook: DiscoveredHook, value: JsonValue, fieldPath: readonly string[]): void { + if (typeof value !== 'string') { + error(context, hook, 'HOOK_MATCHER_INVALID', `Hook "${hook.id}" matcher must be a string.`, fieldPath); + return; + } + if (value === '' || value === '*') + return; + try { + /** 实际 Contributor runtime 使用 JavaScript RegExp。 */ + void new RegExp(value); + } catch { + error(context, hook, 'HOOK_MATCHER_INVALID', `Hook "${hook.id}" matcher is not a valid regular expression.`, fieldPath); + } +} + +/** 校验 timeout 是正有限秒数。 */ +function validateTimeout(context: ExtensionValidateContext, hook: DiscoveredHook, value: JsonValue, fieldPath: readonly string[]): void { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) + error(context, hook, 'HOOK_TIMEOUT_INVALID', `Hook "${hook.id}" timeout must be a positive finite number of seconds.`, fieldPath); +} + +/** 校验 statusMessage 是非空展示文本。 */ +function validateStatus(context: ExtensionValidateContext, hook: DiscoveredHook, value: JsonValue, fieldPath: readonly string[]): void { + if (typeof value !== 'string' || value.trim().length === 0) + error(context, hook, 'HOOK_STATUS_MESSAGE_INVALID', `Hook "${hook.id}" statusMessage must be a non-empty string.`, fieldPath); +} + +/** @returns descriptor 已验证事件的统一名称。 */ +export function eventName(definition: HookDescriptorData): string { + return typeof definition.event === 'string' + ? definition.event + : String((definition.event as Record | null)?.name ?? 'unknown'); +} + +/** 把规范事件映射为兼容性 ID 使用的小写 kebab-case。 */ +export function eventCapability(definition: HookDescriptorData): string { + return eventName(definition).replace(/([a-z0-9])([A-Z])/gu, '$1-$2').toLowerCase(); +} + +/** @returns descriptor 平台限定事件的目标 Platform。 */ +export function platformForEvent(definition: HookDescriptorData): string | undefined { + return typeof definition.event === 'object' && definition.event !== null && !Array.isArray(definition.event) + ? typeof (definition.event as Record).platform === 'string' + ? (definition.event as Record).platform as string + : undefined + : undefined; +} + +/** @returns 当前 Platform 已验证的覆盖对象。 */ +export function platformOptions(definition: HookDescriptorData, platform: string): Readonly> | undefined { + if (!isPlainObject(definition.platforms)) + return undefined; + /** value 是 copyJson 生成的 JSON object。 */ + const value = definition.platforms[platform]; + return isPlainObject(value) ? value as Readonly> : undefined; +} + +/** @returns descriptor 的事件可安全交给类型化运行时代码。 */ +export function hookEvent(definition: HookDescriptorData): HookEventDeclaration { + return definition.event as unknown as HookEventDeclaration; +} + +/** 校验一个 Hook 的事件、plain fields、平台范围和受控 options。 */ +function validateHook( + context: ExtensionValidateContext, + hook: DiscoveredHook, + configuredPlatforms: ReadonlySet, +): void { + for (const field of hook.definition.unknownFields) { + error(context, hook, 'HOOK_FIELD_UNKNOWN', `Hook "${hook.id}" field "${field}" is not part of the authoring contract.`, [field]); + } + if (!hook.definition.runValid) + error(context, hook, 'HOOK_RUN_REQUIRED', `Hook "${hook.id}" must define run().`, ['run']); + /** event 在 plain JSON State 中验证 canonical 或显式单平台形态。 */ + const event = hook.definition.event; + if (typeof event === 'string') { + if (!EVENT_SET.has(event)) + error(context, hook, 'HOOK_EVENT_UNSUPPORTED', `Hook "${hook.id}" must use a canonical event or an explicit platform event.`, ['event']); + } else if (!isPlainObject(event) + || Object.keys(event).some(field => field !== 'platform' && field !== 'name') + || typeof event.platform !== 'string' + || !PLATFORM_ID_PATTERN.test(event.platform) + || typeof event.name !== 'string' + || event.name.trim().length === 0) { + error(context, hook, 'HOOK_PLATFORM_EVENT_INVALID', `Hook "${hook.id}" platform event must contain only platform and name.`, ['event']); + } else if (!configuredPlatforms.has(event.platform)) { + error(context, hook, 'HOOK_PLATFORM_NOT_CONFIGURED', `Hook "${hook.id}" targets unconfigured Platform "${event.platform}".`, ['event', 'platform']); + } else if (event.platform !== CLAUDE_CODE_PLATFORM_ID || !CLAUDE_EVENT_SET.has(event.name) || EVENT_SET.has(event.name)) { + error(context, hook, 'HOOK_PLATFORM_EVENT_UNSUPPORTED', `Hook "${hook.id}" uses an unsupported platform-only event.`, ['event']); + } + if (hook.definition.matcher !== undefined) + validateMatcher(context, hook, hook.definition.matcher, ['matcher']); + if (hook.definition.timeout !== undefined) + validateTimeout(context, hook, hook.definition.timeout, ['timeout']); + if (hook.definition.statusMessage !== undefined) + validateStatus(context, hook, hook.definition.statusMessage, ['statusMessage']); + if (hook.definition.platforms !== undefined && !isPlainObject(hook.definition.platforms)) { + error(context, hook, 'HOOK_PLATFORMS_INVALID', `Hook "${hook.id}" platforms must be an object.`, ['platforms']); + return; + } + for (const [platform, value] of Object.entries(hook.definition.platforms ?? {})) { + if (!configuredPlatforms.has(platform)) { + error(context, hook, 'HOOK_PLATFORM_NOT_CONFIGURED', `Hook "${hook.id}" configures unconfigured Platform "${platform}".`, ['platforms', platform]); + continue; + } + if (!OFFICIAL_PLATFORMS.has(platform) || !isPlainObject(value)) { + error(context, hook, 'HOOK_PLATFORM_OPTIONS_INVALID', `Hook "${hook.id}" has no valid option schema for Platform "${platform}".`, ['platforms', platform]); + continue; + } + /** allowed 是当前官方 Contributor 的精确字段集合。 */ + const allowed = platform === CLAUDE_CODE_PLATFORM_ID ? CLAUDE_FIELDS : platform === CODEX_PLATFORM_ID ? CODEX_FIELDS : PORTABLE_FIELDS; + for (const field of Object.keys(value)) { + if (!allowed.has(field)) + error(context, hook, 'HOOK_PLATFORM_FIELD_UNKNOWN', `Hook "${hook.id}" platforms.${platform}.${field} is unsupported.`, ['platforms', platform, field]); + } + if (value.matcher !== undefined) + validateMatcher(context, hook, value.matcher as JsonValue, ['platforms', platform, 'matcher']); + if (value.timeout !== undefined) + validateTimeout(context, hook, value.timeout as JsonValue, ['platforms', platform, 'timeout']); + if (value.statusMessage !== undefined) + validateStatus(context, hook, value.statusMessage as JsonValue, ['platforms', platform, 'statusMessage']); + if (platform === CODEX_PLATFORM_ID && value.additionalContextLimit !== undefined + && (typeof value.additionalContextLimit !== 'number' || !Number.isInteger(value.additionalContextLimit) || value.additionalContextLimit < 0)) { + error(context, hook, 'HOOK_CONTEXT_LIMIT_INVALID', `Hook "${hook.id}" Codex additionalContextLimit must be a non-negative integer.`, ['platforms', platform, 'additionalContextLimit']); + } + } + /** SessionEnd 使用两个官方平台公开的硬超时上限。 */ + if (eventName(hook.definition) === 'SessionEnd') { + /** codexTimeout 是覆盖或顶层的最终值。 */ + const codexTimeout = platformOptions(hook.definition, CODEX_PLATFORM_ID)?.timeout ?? hook.definition.timeout; + if (configuredPlatforms.has(CODEX_PLATFORM_ID) && typeof codexTimeout === 'number' && codexTimeout > 3) + error(context, hook, 'HOOK_TIMEOUT_PLATFORM_LIMIT', `Hook "${hook.id}" exceeds Codex SessionEnd's 3 second maximum.`, ['platforms', CODEX_PLATFORM_ID, 'timeout']); + /** claudeTimeout 是覆盖或顶层的最终值。 */ + const claudeTimeout = platformOptions(hook.definition, CLAUDE_CODE_PLATFORM_ID)?.timeout ?? hook.definition.timeout; + if (configuredPlatforms.has(CLAUDE_CODE_PLATFORM_ID) && typeof claudeTimeout === 'number' && claudeTimeout > 60) + error(context, hook, 'HOOK_TIMEOUT_PLATFORM_LIMIT', `Hook "${hook.id}" exceeds Claude Code SessionEnd's 60 second maximum.`, ['platforms', CLAUDE_CODE_PLATFORM_ID, 'timeout']); + } +} + +/** 验证全部 Hook 并声明每个事件 tuple 的跨 Platform 覆盖合同。 */ +export function validateHooks( + context: ExtensionValidateContext, + discovered: Readonly, + configuredPlatforms: ReadonlySet, +): { readonly state: ValidatedHooks; readonly subjects: readonly { readonly subject: string; readonly capabilities: readonly string[] }[] } { + for (const hook of discovered.hooks) + validateHook(context, hook, configuredPlatforms); + /** 每个 Hook 的 event capability 必须由每个选中 Platform Contributor 精确覆盖。 */ + const subjects = discovered.hooks.map(hook => Object.freeze({ + subject: `hook:${hook.id}`, + capabilities: Object.freeze([`event.${eventCapability(hook.definition)}`]), + })); + return Object.freeze({ state: discovered, subjects: Object.freeze(subjects) }); +} + +/** 供 Contributor 的固定规范事件类型守卫。 */ +export function isCanonicalEvent(value: string): value is HookEvent { + return EVENT_SET.has(value); +} diff --git a/packages/extensions/hooks/src/index.ts b/packages/extensions/hooks/src/index.ts new file mode 100644 index 0000000..fb47eb7 --- /dev/null +++ b/packages/extensions/hooks/src/index.ts @@ -0,0 +1,117 @@ +import { + defineExtension, + type AcpluginExtension, + type JsonObject, + type PortableNodeCompileOptions, +} from '@tokenroll/acplugin/sdk'; +import { buildHooks, type BuiltHooks } from './build.js'; +import { HOOK_ID_PATTERN } from './constants.js'; +import { createHooksContributors } from './contributors/index.js'; +import { + discoverHooks, + type DiscoveredHooks, + type ValidatedHooks, + validateHooks, +} from './discovery.js'; + +export { EXTENSION_NAME } from './constants.js'; +export { + CLAUDE_CODE_PLATFORM_EVENTS, + HOOK_EVENTS, +} from './types.js'; +export type { + ClaudeCodeHookOptions, + ClaudeCodePlatformHookEvent, + CodexHookOptions, + HookAdvisoryResult, + HookContextResult, + HookDecisionResult, + Hook, + HookEvent, + HookEventDeclaration, + HookFlowResult, + HookInput, + HookInputBase, + HookInputByEvent, + HookPlatformOptions, + HookResult, + HookResultByEvent, + HookRuntimeContext, + PlatformHookEvent, + PortableHookOptions, +} from './types.js'; + +/** 创建 Hooks Extension 时可声明的作者资源和编译参数。 */ +export interface HooksExtensionOptions { + /** 只构建这些 `src/hooks/`;省略时构建全部。 */ + readonly include?: readonly string[]; + /** 复用 Core `portable-node` 的公共纯 JSON 编译参数。 */ + readonly compile?: PortableNodeCompileOptions; +} + +/** 进入 defineExtension 的 JSON-safe options 形态。 */ +type HooksJsonOptions = JsonObject; + +/** Hooks Extension factory 允许的精确字段。 */ +const OPTION_FIELDS = new Set(['include', 'compile']); + +/** 校验并复制可选 Hook ID 白名单。 */ +function normalizedInclude(include: HooksExtensionOptions['include']): readonly string[] | undefined { + if (include === undefined) + return undefined; + if (!Array.isArray(include) || include.some(id => typeof id !== 'string' || !HOOK_ID_PATTERN.test(id))) + throw new TypeError('Hooks include must contain lowercase kebab-case IDs.'); + if (new Set(include).size !== include.length) + throw new TypeError('Hooks include must not contain duplicate IDs.'); + return Object.freeze([...include].sort()); +} + +/** 校验 factory options 顶层和可 JSON 复制的 compile 容器。 */ +function normalizedOptions(options: HooksExtensionOptions): HooksJsonOptions { + if (options === null || typeof options !== 'object' || Array.isArray(options)) + throw new TypeError('Hooks options must be a plain object.'); + for (const field of Object.keys(options)) { + if (!OPTION_FIELDS.has(field)) + throw new TypeError(`Unknown Hooks option "${field}".`); + } + /** include 立即复制,compile 的完整 Schema 由 Core portable-node Host 验证。 */ + const include = normalizedInclude(options.include); + return { + ...(include === undefined ? {} : { include }), + ...(options.compile === undefined ? {} : { compile: options.compile as PortableNodeCompileOptions & JsonObject }), + }; +} + +/** 创建以 plain TS descriptor、Core Host 和无序 Contributors 实现的 Hooks Extension。 */ +export function hooks(options: HooksExtensionOptions = {}): AcpluginExtension { + /** normalized 由 defineExtension 再次防御性复制并深度冻结。 */ + const normalized = normalizedOptions(options); + return defineExtension({ + id: 'hooks', + apiVersion: '1', + options: normalized, + resourceRoots: ['hooks'], + /** 每个 BuildSession 从 setup integrations 派生不可变平台快照。 */ + createSession({ options: sessionOptions, integrations }) { + /** platforms 不依赖 factory closure 或 Extension 配置顺序。 */ + const platforms = new Set(integrations.filter(item => item.kind === 'platform').map(item => item.id)); + /** include 从 Core 已复制的 JSON options 建立 Session-local Set。 */ + const normalized = sessionOptions as HooksExtensionOptions; + /** include 白名单只在当前 Session 内使用。 */ + const include = normalized.include === undefined ? undefined : new Set(normalized.include); + /** compile 同样只读取 setup 的 frozen options。 */ + const compile = normalized.compile; + return { + /** 从 Extension 独占根发现 Hook 作者模块。 */ + discover: context => discoverHooks(context, include), + /** 校验纯数据 descriptor 并登记跨平台主题。 */ + validate: (context, discovered) => validateHooks(context, discovered, platforms), + /** 通过 Core portable-node 一次编译可复用的 Handler。 */ + build: async (context, validated) => ({ state: await buildHooks(context, validated, compile) }), + contributors: createHooksContributors(), + }; + }, + }); +} + +export default hooks; diff --git a/packages/extensions/hooks/src/runtime/integration.ts b/packages/extensions/hooks/src/runtime/integration.ts new file mode 100644 index 0000000..6490d69 --- /dev/null +++ b/packages/extensions/hooks/src/runtime/integration.ts @@ -0,0 +1,191 @@ +import type { BuiltHook } from '../build.js'; +import { eventName } from '../discovery.js'; + +/** 运行时 Platform integration 需要的单个 Hook 静态描述。 */ +interface RuntimeHookDescriptor { + /** 规范 Hook ID。 */ + readonly id: string; + /** 规范事件名。 */ + readonly event: string; + /** 工具事件使用的可选正则 matcher。 */ + readonly matcher?: string; + /** 子进程超时毫秒数。 */ + readonly timeout: number; +} + +/** + * 把 Built Hook 转换为不含函数和源码路径的运行时描述。 + * + * @param hook 已完成平台中立 Bundle 的 Hook。 + * @param matcher 当前 Platform 合并后的 matcher。 + * @param timeout 当前 Platform 合并后的超时秒数。 + * @returns 可安全嵌入生成运行时代码的静态 JSON 数据。 + */ +export function runtimeHookDescriptor( + hook: BuiltHook, + matcher: string | undefined, + timeout: number | undefined, +): RuntimeHookDescriptor { + return Object.freeze({ + id: hook.id, + event: eventName(hook.definition), + ...(matcher === undefined ? {} : { matcher }), + timeout: Math.max(1, Math.round((timeout ?? 30) * 1_000)), + }); +} + +/** + * 创建 OpenCode runtime Plugin 源码。 + * + * 生成代码只依赖 Node 内置模块,通过子进程运行同一平台中立 Handler;Plugin + * 自身负责把 OpenCode callback 输入规范化,并把决策应用回 callback output。 + * + * @param hooks 已筛选为 OpenCode 支持事件的静态描述。 + * @returns 可直接放入 `.opencode/plugins` 的 ESM 源码。 + */ +export function createOpenCodePluginSource(hooks: readonly RuntimeHookDescriptor[]): string { + return ` +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const hooks = ${JSON.stringify(hooks)}; +const pluginFile = fileURLToPath(import.meta.url); +const workspaceRoot = path.resolve(path.dirname(pluginFile), '../..'); + +function matches(hook, input) { + if (!hook.matcher || hook.matcher === '*') return true; + const subject = input.tool_name || input.toolName || input.tool?.name || ''; + return new RegExp(hook.matcher).test(subject); +} + +function execute(hook, input) { + return new Promise((resolve, reject) => { + const handler = path.join(workspaceRoot, '.opencode', 'acplugin-hooks', hook.id, 'handler.mjs'); + const child = spawn(process.execPath, [handler, 'opencode'], { + cwd: workspaceRoot, + env: { ...process.env, PLUGIN_ROOT: workspaceRoot, PLUGIN_DATA: path.join(workspaceRoot, '.opencode', 'data') }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + const timer = setTimeout(() => child.kill('SIGTERM'), hook.timeout); + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', chunk => { stdout += chunk; }); + child.stderr.on('data', chunk => { stderr += chunk; }); + child.on('error', reject); + child.on('close', (code) => { + clearTimeout(timer); + if (code !== 0) reject(new Error('acplugin Hook failed.')); + else resolve(stdout.trim() ? JSON.parse(stdout) : undefined); + }); + child.stdin.end(JSON.stringify({ session_id: '', cwd: workspaceRoot, hook_event_name: hook.event, ...input })); + }); +} + +async function run(event, input, output) { + for (const hook of hooks.filter(candidate => candidate.event === event && matches(candidate, input))) { + const result = await execute(hook, input); + if (!result) continue; + if (result.updatedInput && output && typeof output === 'object') output.args = result.updatedInput; + if (result.additionalContext && output && Array.isArray(output.parts)) + output.parts.push({ type: 'text', text: result.additionalContext, synthetic: true }); + if (result.decision === 'deny' || result.decision === 'block' || result.decision === 'continue') + throw new Error(result.reason || 'Blocked by acplugin Hook.'); + } +} + +export default async function acpluginHooks() { + return { + 'chat.message': (input, output) => run('UserPromptSubmit', { ...input, prompt: input.prompt || input.message || '' }, output), + 'tool.execute.before': (input, output) => run('PreToolUse', input, output), + 'tool.execute.after': (input, output) => run('PostToolUse', input, output), + event: async ({ event }) => { + const mapping = { + 'session.created': 'SessionStart', + 'session.deleted': 'SessionEnd', + 'session.compacted': 'PostCompact', + 'session.idle': 'Stop', + }; + const canonical = mapping[event?.type]; + if (canonical) await run(canonical, event, undefined); + }, + }; +} +`; +} + +/** + * 创建 Pi Extension 源码。 + * + * @param hooks 已筛选为 Pi 支持事件的静态描述。 + * @returns 默认导出 Pi Extension 工厂的 Node ESM 源码。 + */ +export function createPiExtensionSource(hooks: readonly RuntimeHookDescriptor[]): string { + return ` +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const hooks = ${JSON.stringify(hooks)}; +const extensionFile = fileURLToPath(import.meta.url); +const packageRoot = path.resolve(path.dirname(extensionFile), '..'); +const events = { + SessionStart: 'session_start', + SessionEnd: 'session_shutdown', + UserPromptSubmit: 'input', + PreToolUse: 'tool_call', + PostToolUse: 'tool_result', + PreCompact: 'session_before_compact', + PostCompact: 'session_compact', + Stop: 'agent_end', +}; + +function matches(hook, input) { + if (!hook.matcher || hook.matcher === '*') return true; + const subject = input.tool_name || input.toolName || input.tool?.name || ''; + return new RegExp(hook.matcher).test(subject); +} + +function execute(hook, input) { + return new Promise((resolve, reject) => { + const handler = path.join(packageRoot, 'extensions', 'acplugin-hooks', hook.id, 'handler.mjs'); + const child = spawn(process.execPath, [handler, 'pi'], { + cwd: packageRoot, + env: { ...process.env, PLUGIN_ROOT: packageRoot, PLUGIN_DATA: path.join(packageRoot, '.pi-data') }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = ''; + const timer = setTimeout(() => child.kill('SIGTERM'), hook.timeout); + child.stdout.setEncoding('utf8'); + child.stdout.on('data', chunk => { stdout += chunk; }); + child.on('error', reject); + child.on('close', (code) => { + clearTimeout(timer); + if (code !== 0) reject(new Error('acplugin Hook failed.')); + else resolve(stdout.trim() ? JSON.parse(stdout) : undefined); + }); + child.stdin.end(JSON.stringify({ session_id: '', cwd: packageRoot, hook_event_name: hook.event, ...input })); + }); +} + +export default function acpluginHooks(pi) { + for (const hook of hooks) { + const nativeEvent = events[hook.event]; + if (!nativeEvent) continue; + pi.on(nativeEvent, async (event, context) => { + const input = { ...event, cwd: context?.cwd || packageRoot }; + if (!matches(hook, input)) return undefined; + const result = await execute(hook, input); + if (!result) return undefined; + if (hook.event === 'PreToolUse' && result.decision === 'deny') + return { block: true, reason: result.reason || 'Blocked by acplugin Hook.' }; + if (hook.event === 'UserPromptSubmit' && result.decision === 'deny') + return { action: 'handled' }; + return undefined; + }); + } +} +`; +} diff --git a/packages/extensions/hooks/src/runtime/runner.ts b/packages/extensions/hooks/src/runtime/runner.ts new file mode 100644 index 0000000..dacfceb --- /dev/null +++ b/packages/extensions/hooks/src/runtime/runner.ts @@ -0,0 +1,221 @@ +import { MAX_HOOK_IO_BYTES } from '../constants.js'; + +/** + * 生成单个 Hook 的平台中立隔离运行器源码。 + * + * Handler 拥有有限 I/O、规范结果校验、用户实现调用和内联的官方平台 wire。 + * wire 作为虚拟模块一同 Bundle,使最终可执行文件不依赖任何相邻 JavaScript。 + * 生成字符串属于最终 Plugin 运行时代码,不机械注入开发期中文注释。 + * + * @returns 可交给 Rolldown 的 Node 20 ESM 入口源码。 + */ +export function createRunnerSource(): string { + return ` +import { contextFor, inputFor, outputFor } from 'acplugin:hook-wire'; + +const MAX_BYTES = ${MAX_HOOK_IO_BYTES}; +const MAX_JSON_DEPTH = 128; +const PLATFORM_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const EVENT_RESULTS = { + SessionStart: { decisions: ['continue', 'stop'], fields: ['reason', 'additionalContext'] }, + SessionEnd: { decisions: [], fields: [] }, + UserPromptSubmit: { decisions: ['allow', 'deny'], fields: ['reason', 'additionalContext'] }, + PreToolUse: { decisions: ['allow', 'deny'], fields: ['reason', 'updatedInput', 'additionalContext'] }, + PermissionRequest: { decisions: ['allow', 'deny', 'defer'], fields: ['reason'] }, + PostToolUse: { decisions: ['pass', 'block'], fields: ['reason', 'additionalContext'] }, + PreCompact: { decisions: ['continue', 'stop'], fields: ['reason'] }, + PostCompact: { decisions: ['continue', 'stop'], fields: ['reason'] }, + SubagentStart: { decisions: [], fields: ['additionalContext'] }, + SubagentStop: { decisions: ['finish', 'continue'], fields: ['reason'] }, + Stop: { decisions: ['finish', 'continue'], fields: ['reason'] }, +}; +const ERROR_CODES = new Set([ + 'HANDLER_ASYNC_FAILED', 'HANDLER_EXIT_FORBIDDEN', 'HANDLER_FAILED', 'HANDLER_IMPORT_FAILED', + 'HANDLER_INCOMPLETE', 'HANDLER_OUTPUT_FORBIDDEN', 'HANDLER_OUTPUT_TOO_LARGE', + 'INPUT_COMMON_INVALID', 'INPUT_EVENT_INVALID', 'INPUT_EVENT_MISMATCH', 'INPUT_JSON_INVALID', + 'INPUT_KEY_COLLISION', 'INPUT_OBJECT_REQUIRED', 'INPUT_TOO_DEEP', 'INPUT_TOO_LARGE', + 'OUTPUT_TOO_LARGE', 'PLATFORM_EVENT_MISMATCH', 'PLATFORM_INVALID', 'RESULT_DECISION_INVALID', + 'RESULT_EVENT_INVALID', 'RESULT_FIELD_INVALID', 'RESULT_INVALID', 'RESULT_SERIALIZATION_FAILED', + 'RESULT_UPDATED_INPUT_INVALID', 'WIRE_CONTEXT_INVALID', +]); + +function isJsonValue(value, depth = 0, ancestors = new Set()) { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return true; + if (typeof value === 'number') return Number.isFinite(value); + if (typeof value !== 'object' || depth > MAX_JSON_DEPTH || ancestors.has(value)) return false; + const prototype = Object.getPrototypeOf(value); + if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) return false; + ancestors.add(value); + try { + if (Array.isArray(value)) { + for (let index = 0; index < value.length; index += 1) { + if (!Object.hasOwn(value, index) || !isJsonValue(value[index], depth + 1, ancestors)) return false; + } + return true; + } + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string' || !isJsonValue(value[key], depth + 1, ancestors)) return false; + } + return true; + } catch { + return false; + } finally { + ancestors.delete(value); + } +} + +function validateResult(event, result, isPlatformEvent) { + if (result === undefined) return; + if (!result || typeof result !== 'object' || Array.isArray(result)) throw new Error('RESULT_INVALID'); + const contract = isPlatformEvent ? { decisions: [], fields: [] } : EVENT_RESULTS[event]; + if (!contract) throw new Error('RESULT_EVENT_INVALID'); + const allowedFields = new Set(['decision', 'systemMessage', ...contract.fields]); + if (Object.keys(result).some(field => !allowedFields.has(field))) throw new Error('RESULT_FIELD_INVALID'); + for (const field of ['reason', 'additionalContext', 'systemMessage']) { + if (result[field] !== undefined && typeof result[field] !== 'string') throw new Error('RESULT_INVALID'); + } + if (result.decision !== undefined && !contract.decisions.includes(result.decision)) + throw new Error('RESULT_DECISION_INVALID'); + if (result.updatedInput !== undefined) { + if (event !== 'PreToolUse' || result.decision !== 'allow' || !isJsonValue(result.updatedInput)) + throw new Error('RESULT_UPDATED_INPUT_INVALID'); + } +} + +async function readInput() { + const chunks = []; + let byteLength = 0; + for await (const value of process.stdin) { + const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value); + byteLength += chunk.byteLength; + if (byteLength > MAX_BYTES) throw new Error('INPUT_TOO_LARGE'); + chunks.push(chunk); + } + try { + return JSON.parse(Buffer.concat(chunks, byteLength).toString('utf8')); + } catch { + throw new Error('INPUT_JSON_INVALID'); + } +} + +async function loadDefinition() { + try { + const namespace = await import('./hook.ts'); + return namespace.default; + } catch { + throw new Error('HANDLER_IMPORT_FAILED'); + } +} + +const safeStdout = process.stdout.write.bind(process.stdout); +const safeStderr = process.stderr.write.bind(process.stderr); +let interceptedBytes = 0; +let interceptedFailure; +let serializedOutput; +let failureCode; +let mainCompleted = false; +let finalized = false; +let finalizationArmed = false; + +function stableErrorCode(error, fallback) { + return error instanceof Error && ERROR_CODES.has(error.message) ? error.message : fallback; +} + +function intercept(chunk, encoding, callback) { + interceptedBytes += Buffer.byteLength(chunk); + if (interceptedBytes > MAX_BYTES) { + interceptedFailure = 'HANDLER_OUTPUT_TOO_LARGE'; + throw new Error(interceptedFailure); + } + const completed = typeof encoding === 'function' ? encoding : callback; + if (typeof completed === 'function') queueMicrotask(completed); + return true; +} + +process.stdout.write = intercept; +process.stderr.write = intercept; +process.exit = () => { + throw new Error('HANDLER_EXIT_FORBIDDEN'); +}; + +process.on('uncaughtException', (error) => { + failureCode = stableErrorCode(error, 'HANDLER_ASYNC_FAILED'); + process.exitCode = 1; +}); + +process.on('unhandledRejection', (error) => { + failureCode = stableErrorCode(error, 'HANDLER_ASYNC_FAILED'); + process.exitCode = 1; +}); + +function finalize() { + if (finalized) return; + finalized = true; + if (!mainCompleted && failureCode === undefined) failureCode = 'HANDLER_INCOMPLETE'; + if (interceptedFailure !== undefined) failureCode = interceptedFailure; + else if (interceptedBytes > 0 && failureCode === undefined) failureCode = 'HANDLER_OUTPUT_FORBIDDEN'; + if (failureCode !== undefined) { + safeStderr('acplugin hook error: ' + failureCode + '\\n'); + process.exitCode = 1; + } else if (serializedOutput !== undefined) { + safeStdout(serializedOutput + '\\n'); + } +} + +process.on('beforeExit', () => { + if (finalized) return; + if (finalizationArmed) { + finalize(); + return; + } + finalizationArmed = true; + setImmediate(() => {}); +}); + +async function main() { + const platform = process.argv[2]; + if (typeof platform !== 'string' || !PLATFORM_PATTERN.test(platform)) throw new Error('PLATFORM_INVALID'); + const definition = await loadDefinition(); + const declaredEvent = definition && definition.event; + const expectedEvent = typeof declaredEvent === 'string' ? declaredEvent : declaredEvent && declaredEvent.name; + const platformEvent = typeof declaredEvent === 'object' && declaredEvent !== null; + if (platformEvent && declaredEvent.platform !== platform) throw new Error('PLATFORM_EVENT_MISMATCH'); + const raw = await readInput(); + const input = inputFor(platform, raw, expectedEvent, declaredEvent); + const runtimeContext = contextFor(platform, process.env); + if (!runtimeContext + || typeof runtimeContext !== 'object' + || typeof runtimeContext.pluginRoot !== 'string' + || typeof runtimeContext.pluginData !== 'string') + throw new Error('WIRE_CONTEXT_INVALID'); + let result; + try { + result = await definition.run(input, Object.freeze({ + platform, + pluginRoot: runtimeContext.pluginRoot, + pluginData: runtimeContext.pluginData, + })); + } catch { + throw new Error('HANDLER_FAILED'); + } + validateResult(expectedEvent, result, platformEvent); + const output = outputFor(platform, expectedEvent, result); + if (output) { + try { + serializedOutput = JSON.stringify(output); + } catch { + throw new Error('RESULT_SERIALIZATION_FAILED'); + } + if (Buffer.byteLength(serializedOutput) > MAX_BYTES) throw new Error('OUTPUT_TOO_LARGE'); + } +} + +main().then(() => { + mainCompleted = true; +}).catch((error) => { + mainCompleted = true; + failureCode = stableErrorCode(error, 'HOOK_FAILED'); + process.exitCode = 1; +}); +`; +} diff --git a/packages/extensions/hooks/src/runtime/wire.ts b/packages/extensions/hooks/src/runtime/wire.ts new file mode 100644 index 0000000..d07ff70 --- /dev/null +++ b/packages/extensions/hooks/src/runtime/wire.ts @@ -0,0 +1,204 @@ +import { + ANTIGRAVITY_PLATFORM_ID, + CLAUDE_CODE_PLATFORM_ID, + CODEX_PLATFORM_ID, + CURSOR_PLATFORM_ID, + OPENCODE_PLATFORM_ID, + PI_PLATFORM_ID, +} from '../constants.js'; + +/** Hooks Extension 当前内置 Contributor 的 Platform ID。 */ +export type HookAdapterPlatform + = | typeof CLAUDE_CODE_PLATFORM_ID + | typeof CODEX_PLATFORM_ID + | typeof CURSOR_PLATFORM_ID + | typeof ANTIGRAVITY_PLATFORM_ID + | typeof OPENCODE_PLATFORM_ID + | typeof PI_PLATFORM_ID; + +/** + * 创建内联到平台中立 Handler 的官方 Hook wire profiles。 + * + * profiles 负责原生 stdin 校验、camelCase 输入转换、运行目录解析和规范结果 + * 映射。它作为稳定虚拟模块进入同一个自包含 portable-node Bundle,因此运行时 + * 不依赖 Contributor 后续补写的 JavaScript 文件。 + * + * @returns 可作为 Core Build Service 虚拟模块的 ESM 源码。 + */ +export function createWireSource(): string { + /** 每个平台只保存环境变量名称,任何环境值都留到 Plugin 真实运行时读取。 */ + const platformProfiles: Record = { + [CLAUDE_CODE_PLATFORM_ID]: ['CLAUDE_PLUGIN_ROOT', 'PLUGIN_ROOT', 'CLAUDE_PLUGIN_DATA', 'PLUGIN_DATA'], + [CODEX_PLATFORM_ID]: ['PLUGIN_ROOT', 'CLAUDE_PLUGIN_ROOT', 'PLUGIN_DATA', 'CLAUDE_PLUGIN_DATA'], + [CURSOR_PLATFORM_ID]: ['CURSOR_PLUGIN_ROOT', 'CLAUDE_PLUGIN_ROOT', 'PLUGIN_DATA', 'CLAUDE_PLUGIN_DATA'], + [ANTIGRAVITY_PLATFORM_ID]: ['ANTIGRAVITY_PLUGIN_ROOT', 'CLAUDE_PLUGIN_ROOT', 'PLUGIN_DATA', 'CLAUDE_PLUGIN_DATA'], + [OPENCODE_PLATFORM_ID]: ['PLUGIN_ROOT', 'CLAUDE_PLUGIN_ROOT', 'PLUGIN_DATA', 'CLAUDE_PLUGIN_DATA'], + [PI_PLATFORM_ID]: ['PLUGIN_ROOT', 'CLAUDE_PLUGIN_ROOT', 'PLUGIN_DATA', 'CLAUDE_PLUGIN_DATA'], + }; + return ` +const PLATFORM_PROFILES = ${JSON.stringify(platformProfiles)}; + +export function contextFor(platform, environment) { + const profile = PLATFORM_PROFILES[platform]; + if (!profile) throw new Error('PLATFORM_INVALID'); + return { + pluginRoot: environment[profile[0]] || environment[profile[1]] || '', + pluginData: environment[profile[2]] || environment[profile[3]] || '', + }; +} + +const MAX_DEPTH = 128; +const EVENT_INPUTS = { + SessionStart: { source: 'string' }, + SessionEnd: { reason: 'string' }, + UserPromptSubmit: { prompt: 'string' }, + PreToolUse: { tool_name: 'string', tool_input: 'present', tool_use_id: 'string' }, + PermissionRequest: { tool_name: 'string', tool_input: 'present' }, + PostToolUse: { tool_name: 'string', tool_input: 'present', tool_use_id: 'string', tool_response: 'present' }, + PreCompact: { trigger: 'string' }, + PostCompact: { trigger: 'string' }, + SubagentStart: { agent_id: 'string', agent_type: 'string' }, + SubagentStop: { agent_id: 'string', agent_type: 'string' }, + Stop: { stop_hook_active: 'boolean' }, +}; + +function camel(key) { + return key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); +} + +function normalize(value, depth = 0) { + if (depth > MAX_DEPTH) throw new Error('INPUT_TOO_DEEP'); + if (Array.isArray(value)) return value.map(child => normalize(child, depth + 1)); + if (value && typeof value === 'object') { + const entries = []; + const fields = new Set(); + for (const [key, child] of Object.entries(value)) { + const field = camel(key); + if (fields.has(field)) throw new Error('INPUT_KEY_COLLISION'); + fields.add(field); + entries.push([field, normalize(child, depth + 1)]); + } + return Object.fromEntries(entries); + } + return value; +} + +function validateInput(raw, expectedEvent) { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('INPUT_OBJECT_REQUIRED'); + if (typeof raw.session_id !== 'string' || typeof raw.cwd !== 'string') throw new Error('INPUT_COMMON_INVALID'); + if (raw.transcript_path !== undefined && raw.transcript_path !== null && typeof raw.transcript_path !== 'string') + throw new Error('INPUT_COMMON_INVALID'); + if (raw.hook_event_name !== expectedEvent) throw new Error('INPUT_EVENT_MISMATCH'); + const contract = EVENT_INPUTS[expectedEvent]; + if (!contract) return; + for (const [field, type] of Object.entries(contract)) { + if (type === 'present' ? !(field in raw) : typeof raw[field] !== type) + throw new Error('INPUT_EVENT_INVALID'); + } +} + +export function inputFor(platform, raw, expectedEvent, declaredEvent) { + if (platform !== 'claude-code' && platform !== 'codex') { + raw = { + ...raw, + session_id: raw.session_id ?? raw.sessionId ?? raw.conversation_id ?? '', + cwd: raw.cwd ?? raw.workspaceRoot ?? '', + hook_event_name: expectedEvent, + }; + if (expectedEvent === 'SessionStart') raw.source ??= 'startup'; + if (expectedEvent === 'SessionEnd') raw.reason ??= 'complete'; + if (expectedEvent === 'UserPromptSubmit') raw.prompt ??= raw.message ?? raw.text ?? ''; + if (expectedEvent === 'PreToolUse' || expectedEvent === 'PostToolUse' || expectedEvent === 'PermissionRequest') { + raw.tool_name ??= raw.toolName ?? raw.tool?.name ?? ''; + raw.tool_input ??= raw.toolInput ?? raw.input ?? raw.args ?? {}; + raw.tool_use_id ??= raw.toolUseId ?? raw.callId ?? ''; + } + if (expectedEvent === 'PostToolUse') raw.tool_response ??= raw.toolResponse ?? raw.output ?? raw.result ?? null; + if (expectedEvent === 'PreCompact' || expectedEvent === 'PostCompact') raw.trigger ??= raw.trigger ?? 'automatic'; + if (expectedEvent === 'SubagentStart' || expectedEvent === 'SubagentStop') { + raw.agent_id ??= raw.agentId ?? ''; + raw.agent_type ??= raw.agentType ?? ''; + } + if (expectedEvent === 'Stop') raw.stop_hook_active ??= raw.stopHookActive ?? false; + } + validateInput(raw, expectedEvent); + const input = normalize(raw); + input.event = declaredEvent; + return input; +} + +function addContext(output, event, additionalContext) { + if (!additionalContext) return; + output.hookSpecificOutput = { hookEventName: event, additionalContext }; +} + +export function outputFor(platform, event, result) { + if (!result) return undefined; + if (platform === 'opencode' || platform === 'pi') return { event, ...result }; + const output = {}; + if (result.systemMessage) output.systemMessage = result.systemMessage; + if (event === 'PreToolUse') { + if (result.decision === 'allow' || result.decision === 'deny') { + output.hookSpecificOutput = { + hookEventName: event, + permissionDecision: result.decision, + ...(result.reason ? { permissionDecisionReason: result.reason } : {}), + ...(result.updatedInput === undefined ? {} : { updatedInput: result.updatedInput }), + ...(result.additionalContext ? { additionalContext: result.additionalContext } : {}), + }; + } else { + addContext(output, event, result.additionalContext); + } + } else if (event === 'PermissionRequest') { + if (result.decision === 'allow' || result.decision === 'deny') { + output.hookSpecificOutput = { + hookEventName: event, + decision: { + behavior: result.decision, + ...(result.reason ? { message: result.reason } : {}), + }, + }; + } else if (result.decision === 'defer' && result.reason && !output.systemMessage) { + output.systemMessage = result.reason; + } + } else if (event === 'PostToolUse') { + if (result.decision === 'block') { + output.decision = 'block'; + output.reason = result.reason || 'Blocked by hook.'; + } + addContext(output, event, result.additionalContext); + } else if (event === 'UserPromptSubmit') { + if (result.decision === 'deny') { + output.decision = 'block'; + output.reason = result.reason || 'Blocked by hook.'; + } + addContext(output, event, result.additionalContext); + } else if (event === 'Stop' || event === 'SubagentStop') { + if (result.decision === 'continue') { + output.decision = 'block'; + output.reason = result.reason || 'Continue before stopping.'; + } + } else if (event === 'SessionStart') { + if (result.decision === 'stop') { + output.continue = false; + output.stopReason = result.reason || 'Stopped by hook.'; + } + addContext(output, event, result.additionalContext); + } else if (event === 'PreCompact' && result.decision === 'stop') { + if (platform === 'claude-code') { + output.decision = 'block'; + output.reason = result.reason || 'Compaction stopped by hook.'; + } else { + output.continue = false; + output.stopReason = result.reason || 'Compaction stopped by hook.'; + } + } else if (event === 'PostCompact' && result.decision === 'stop') { + output.continue = false; + output.stopReason = result.reason || 'Compaction stopped by hook.'; + } else if (event === 'SubagentStart') { + addContext(output, event, result.additionalContext); + } + return Object.keys(output).length ? output : undefined; +} +`; +} diff --git a/packages/extensions/hooks/src/types.ts b/packages/extensions/hooks/src/types.ts new file mode 100644 index 0000000..99ad889 --- /dev/null +++ b/packages/extensions/hooks/src/types.ts @@ -0,0 +1,256 @@ +import type { JsonValue } from '@tokenroll/acplugin/sdk'; + +/** acplugin 1.0 在所有官方 Contributor 之间保持稳定语义的 Hook 事件。 */ +export const HOOK_EVENTS = [ + 'SessionStart', + 'SessionEnd', + 'UserPromptSubmit', + 'PreToolUse', + 'PermissionRequest', + 'PostToolUse', + 'PreCompact', + 'PostCompact', + 'SubagentStart', + 'SubagentStop', + 'Stop', +] as const; + +/** Claude Code 当前公开、但不进入 acplugin 规范事件联合类型的专属事件。 */ +export const CLAUDE_CODE_PLATFORM_EVENTS = [ + 'Setup', + 'UserPromptExpansion', + 'PermissionDenied', + 'PostToolUseFailure', + 'PostToolBatch', + 'Notification', + 'MessageDisplay', + 'TaskCreated', + 'TaskCompleted', + 'StopFailure', + 'TeammateIdle', + 'InstructionsLoaded', + 'ConfigChange', + 'CwdChanged', + 'DirectoryAdded', + 'FileChanged', + 'WorktreeCreate', + 'WorktreeRemove', + 'Elicitation', + 'ElicitationResult', +] as const; + +/** 规范 Hook 事件名称联合类型。 */ +export type HookEvent = typeof HOOK_EVENTS[number]; + +/** 当前 Claude Code Contributor 能识别的平台专属事件名称。 */ +export type ClaudeCodePlatformHookEvent = typeof CLAUDE_CODE_PLATFORM_EVENTS[number]; + +/** 把非规范事件显式限定到一个 Platform,避免悄然污染可移植事件集合。 */ +export interface PlatformHookEvent { + /** 唯一接收该事件的 Platform ID。 */ + readonly platform: string; + /** 由对应 Contributor Schema 识别的平台原生事件名。 */ + readonly name: string; +} + +/** Hook 作者可以声明的规范事件或显式平台限定事件。 */ +export type HookEventDeclaration = HookEvent | PlatformHookEvent; + +/** 所有 Hook 输入共享的 camelCase 会话字段。 */ +export interface HookInputBase { + /** 当前定义声明的规范事件或平台限定事件。 */ + readonly event: Event; + /** 当前 AI 平台会话 ID。 */ + readonly sessionId: string; + /** 平台提供时的会话记录文件路径。 */ + readonly transcriptPath?: string | null; + /** Hook 触发时的工作目录。 */ + readonly cwd: string; + /** 平台提供时的权限模式。 */ + readonly permissionMode?: string; + /** 保留经过递归 camelCase 规范化的平台扩展字段。 */ + readonly [field: string]: unknown; +} + +/** 每个规范事件在共享字段之外保证提供的 camelCase 输入。 */ +export interface HookInputByEvent { + /** 会话开始原因。 */ + readonly SessionStart: { readonly source: string }; + /** 会话结束原因。 */ + readonly SessionEnd: { readonly reason: string }; + /** 即将提交给模型的用户提示。 */ + readonly UserPromptSubmit: { readonly prompt: string }; + /** 工具调用执行前的名称、输入和调用 ID。 */ + readonly PreToolUse: { + readonly toolName: string; + readonly toolInput: unknown; + readonly toolUseId: string; + }; + /** 即将进入平台审批流程的工具调用。 */ + readonly PermissionRequest: { + readonly toolName: string; + readonly toolInput: unknown; + readonly toolUseId?: string; + }; + /** 已完成工具调用的输入和平台结果。 */ + readonly PostToolUse: { + readonly toolName: string; + readonly toolInput: unknown; + readonly toolUseId: string; + readonly toolResponse: unknown; + }; + /** 压缩前的触发原因和可选自定义指令。 */ + readonly PreCompact: { + readonly trigger: string; + readonly customInstructions?: string | null; + }; + /** 压缩完成后的触发原因。 */ + readonly PostCompact: { readonly trigger: string }; + /** 新启动子代理的身份和类型。 */ + readonly SubagentStart: { + readonly agentId: string; + readonly agentType: string; + }; + /** 准备停止子代理时的平台状态。 */ + readonly SubagentStop: { + readonly agentId: string; + readonly agentType: string; + readonly stopHookActive?: boolean; + readonly lastAssistantMessage?: string | null; + }; + /** 主流程准备停止时的平台状态。 */ + readonly Stop: { + readonly stopHookActive: boolean; + readonly lastAssistantMessage?: string | null; + }; +} + +/** 根据事件声明选择精确的规范输入;平台事件保留共享和扩展字段。 */ +export type HookInput + = HookInputBase + & (Event extends HookEvent ? HookInputByEvent[Event] : Readonly>); + +/** 由生成的 Handler 提供给用户实现的只读运行时上下文。 */ +export interface HookRuntimeContext { + /** 当前实际触发 Handler 的 Platform。 */ + readonly platform: string; + /** 已安装 Plugin 的只读根目录。 */ + readonly pluginRoot: string; + /** 平台为 Plugin 提供的可写持久数据目录。 */ + readonly pluginData: string; +} + +/** 只向平台或用户界面提供提示、不改变控制流的结果。 */ +export interface HookAdvisoryResult { + /** 平台支持时显示的系统级消息。 */ + readonly systemMessage?: string; +} + +/** 可以向模型会话追加上下文的结果。 */ +export interface HookContextResult extends HookAdvisoryResult { + /** 追加到当前模型上下文的文本。 */ + readonly additionalContext?: string; +} + +/** 携带决策和可选解释的规范结果。 */ +export interface HookDecisionResult extends HookAdvisoryResult { + /** 当前事件允许的规范决策值。 */ + readonly decision?: Decision; + /** 随决策提供给平台的安全解释。 */ + readonly reason?: string; +} + +/** 控制生命周期继续或停止的规范结果。 */ +export interface HookFlowResult extends HookAdvisoryResult { + /** 当前流程允许的规范决策值。 */ + readonly decision?: Decision; + /** 平台支持时用于说明停止或继续原因的文本。 */ + readonly reason?: string; +} + +/** 每个规范事件允许返回的精确 camelCase 结果。 */ +export interface HookResultByEvent { + /** 会话开始时可以追加上下文或停止当前流程。 */ + readonly SessionStart: HookContextResult & HookFlowResult<'continue' | 'stop'>; + /** 会话结束结果只提供 advisory 信息。 */ + readonly SessionEnd: HookAdvisoryResult; + /** 用户提示提交前可以拒绝提示或追加上下文。 */ + readonly UserPromptSubmit: HookContextResult & HookDecisionResult<'allow' | 'deny'>; + /** 工具执行前可以决策、替换输入并追加上下文。 */ + readonly PreToolUse: HookContextResult & HookDecisionResult<'allow' | 'deny'> & { + readonly updatedInput?: JsonValue; + }; + /** 权限请求可以直接允许、拒绝或交回平台处理。 */ + readonly PermissionRequest: HookDecisionResult<'allow' | 'deny' | 'defer'>; + /** 工具执行后可以放行或把反馈作为阻断结果。 */ + readonly PostToolUse: HookContextResult & HookDecisionResult<'pass' | 'block'>; + /** 压缩前可以继续或停止压缩。 */ + readonly PreCompact: HookFlowResult<'continue' | 'stop'>; + /** 压缩后可以继续或停止后续流程。 */ + readonly PostCompact: HookFlowResult<'continue' | 'stop'>; + /** 子代理开始时可以追加上下文。 */ + readonly SubagentStart: HookContextResult; + /** 子代理结束前可以完成或要求继续。 */ + readonly SubagentStop: HookDecisionResult<'finish' | 'continue'>; + /** 主流程结束前可以完成或要求继续。 */ + readonly Stop: HookDecisionResult<'finish' | 'continue'>; +} + +/** 根据事件选择结果类型;平台专属事件首期只开放 advisory 输出。 */ +export type HookResult + = void | (Event extends HookEvent ? HookResultByEvent[Event] : HookAdvisoryResult); + +/** Claude Code Contributor 允许覆盖的单 Hook 平台字段。 */ +export interface ClaudeCodeHookOptions { + /** 覆盖当前 Hook 的 Claude Code matcher。 */ + readonly matcher?: string; + /** 覆盖当前 Hook 的 Claude Code 超时秒数。 */ + readonly timeout?: number; + /** 覆盖 Hook 执行期间显示的状态消息。 */ + readonly statusMessage?: string; +} + +/** Codex Contributor 允许覆盖的单 Hook 平台字段。 */ +export interface CodexHookOptions extends ClaudeCodeHookOptions { + /** 调整 Codex 在溢写前直接注入模型的上下文 Token 上限。 */ + readonly additionalContextLimit?: number; +} + +/** Cursor、Antigravity、OpenCode 与 Pi Contributor 共享的受控执行选项。 */ +export interface PortableHookOptions { + /** 覆盖当前 Hook 的工具或事件匹配表达式。 */ + readonly matcher?: string; + /** 覆盖 Handler 的超时秒数。 */ + readonly timeout?: number; + /** 平台支持时显示的 Handler 状态消息。 */ + readonly statusMessage?: string; +} + +/** Hook 的平台专属补充字段;已知 Platform 获得精确类型,其他键由 Contributor 验证。 */ +export type HookPlatformOptions = Readonly<{ + readonly 'claude-code'?: ClaudeCodeHookOptions; + readonly 'codex'?: CodexHookOptions; + readonly 'cursor'?: PortableHookOptions; + readonly 'antigravity'?: PortableHookOptions; + readonly 'opencode'?: PortableHookOptions; + readonly 'pi'?: PortableHookOptions; +}> & Readonly>; + +/** 单个 `src/hooks//hook.ts` 默认导出的完整 Hook 契约。 */ +export interface Hook { + /** 需要订阅的规范事件或显式平台限定事件。 */ + readonly event: Event; + /** 所有 Contributor 默认继承的匹配表达式。 */ + readonly matcher?: string; + /** 所有 Contributor 默认继承的 Handler 超时秒数。 */ + readonly timeout?: number; + /** 平台支持时显示的 Handler 状态消息。 */ + readonly statusMessage?: string; + /** 按 Platform ID 补充且由对应 Contributor Schema 验证的字段。 */ + readonly platforms?: HookPlatformOptions; + /** 处理 camelCase 输入并返回对应事件的规范结果。 */ + readonly run: ( + input: HookInput, + context: HookRuntimeContext, + ) => HookResult | Promise>; +} diff --git a/packages/extensions/hooks/test/authoring-discovery.test.ts b/packages/extensions/hooks/test/authoring-discovery.test.ts new file mode 100644 index 0000000..06986fc --- /dev/null +++ b/packages/extensions/hooks/test/authoring-discovery.test.ts @@ -0,0 +1,162 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import hooks from '../src/index.js'; +import { createProject, runProject } from './fixture.js'; + +describe('Hooks Extension authoring and discovery', () => { + it('filters discovered resources with include and rejects invalid factory options', async () => { + /** 只选择 keep、忽略 skip 的真实作者工程。 */ + const root = await createProject({ + hooks: [ + { id: 'keep', definition: `{ event: 'SessionStart', run() {} }` }, + { id: 'skip', definition: `{ event: 'Stop', run() {} }` }, + ], + hooksOptions: `{ include: ['keep'] }`, + }); + /** include 筛选后的双 Platform 构建结果。 */ + const result = await runProject({ cwd: root, command: 'build', mode: 'production' }); + + expect(result.success).toBe(true); + expect((await fs.readdir(path.join(root, 'dist/claude-code/plugin/hooks'))).sort()).toEqual(['hooks.json', 'keep']); + await expect(fs.access(path.join(root, 'dist/codex/plugin/hooks/skip/handler.mjs'))).rejects.toThrow(); + expect(() => hooks({ include: ['valid', 'valid'] })).toThrow('duplicate ID'); + expect(() => hooks({ include: ['Not-Kebab'] })).toThrow('lowercase kebab-case'); + expect(() => hooks({ unknown: true } as never)).toThrow('Unknown Hooks option'); + }); + + it('rejects non-enumerable descriptor accessors without evaluating them', async () => { + /** 不可枚举 getter 也属于可执行描述行为,不能靠 Object.keys 隐藏。 */ + const root = await createProject({ + hooks: [{ + id: 'accessor', + definition: `(() => { + const value = { event: 'SessionStart', run() {} }; + Object.defineProperty(value, 'hidden', { get() { throw new Error('MUST_NOT_RUN'); } }); + return value; + })() as never`, + }], + }); + /** discover 只报告脱敏加载失败,不执行或泄漏 getter 内容。 */ + const result = await runProject({ cwd: root, command: 'validate', mode: 'production' }); + + expect(result.success).toBe(false); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'HOOK_LOAD_FAILED' })); + expect(JSON.stringify(result.diagnostics)).not.toContain('MUST_NOT_RUN'); + }); + + it('rejects non-enumerable unknown descriptor fields', async () => { + /** data property 即使不可枚举也必须保留到领域 Schema 检查。 */ + const root = await createProject({ + hooks: [{ + id: 'hidden-field', + definition: `(() => { + const value = { event: 'SessionStart', run() {} }; + Object.defineProperty(value, 'hidden', { value: true }); + return value; + })() as never`, + }], + }); + /** 隐藏字段不能因 Module Service 快照规则而消失。 */ + const result = await runProject({ cwd: root, command: 'validate', mode: 'production' }); + + expect(result.success).toBe(false); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'HOOK_FIELD_UNKNOWN' })); + }); + + it('distinguishes omitted descriptor fields from nested undefined values', async () => { + /** 顶层可选字段缺失是合法 omission。 */ + const omittedRoot = await createProject({ + hooks: [{ + id: 'omitted', + definition: `{ event: 'SessionStart', run() {} }`, + }], + }); + /** omission 能完整进入 validate/build,而不是被误判成非法 JSON。 */ + const omitted = await runProject({ cwd: omittedRoot, command: 'validate', mode: 'production' }); + expect(omitted.success).toBe(true); + expect(omitted.extensions).toContainEqual(expect.objectContaining({ + id: 'hooks', + discovered: true, + subjects: expect.arrayContaining([ + expect.objectContaining({ subject: 'hook:omitted' }), + ]), + })); + + /** 已出现的嵌套字段显式 undefined 不是 JSON 数据。 */ + const invalidRoot = await createProject({ + hooks: [{ + id: 'nested-undefined', + definition: `{ event: 'SessionStart', platforms: { codex: { timeout: undefined } }, run() {} } as never`, + }], + }); + /** discover 必须只拒绝包含显式 nested undefined 的 descriptor。 */ + const invalid = await runProject({ cwd: invalidRoot, command: 'validate', mode: 'production' }); + + expect(invalid.success).toBe(false); + expect(invalid.diagnostics.filter(diagnostic => diagnostic.code === 'HOOK_LOAD_FAILED')).toHaveLength(1); + }); + + it('rejects array accessors, custom fields, Symbols and __proto__ fields without executing accessors', async () => { + /** 四个资源分别覆盖 nested array getter、自定义索引、数组 Symbol 与特殊对象字段名。 */ + const root = await createProject({ + hooks: [{ + id: 'nested-accessor', + definition: `(() => { + const platforms = []; + Object.defineProperty(platforms, '0', { get() { process.stdout.write('GETTER_EXECUTED'); return 'codex'; } }); + Object.defineProperty(platforms, 'length', { value: 1 }); + return { event: 'SessionStart', platforms, run() {} }; + })() as never`, + }, { + id: 'array-field', + definition: `(() => { + const platforms = ['codex']; + Object.defineProperty(platforms, '01', { value: 'claude-code' }); + return { event: 'SessionStart', platforms, run() {} }; + })() as never`, + }, { + id: 'array-symbol', + definition: `(() => { + const platforms = ['codex']; + Object.defineProperty(platforms, Symbol.for('hidden'), { value: true }); + return { event: 'SessionStart', platforms, run() {} }; + })() as never`, + }, { + id: 'proto-field', + definition: `(() => { + const value = { event: 'SessionStart', run() {} }; + Object.defineProperty(value, '__proto__', { value: true }); + return value; + })() as never`, + }], + }); + /** getter 资源加载失败,特殊字段资源进入领域未知字段诊断。 */ + const result = await runProject({ cwd: root, command: 'validate', mode: 'production' }); + + expect(result.success).toBe(false); + expect(result.diagnostics.filter(diagnostic => diagnostic.code === 'HOOK_LOAD_FAILED')).toHaveLength(3); + }); + + it('reports missing includes and platform-specific SessionEnd timeout limits', async () => { + /** 同时覆盖缺失 include 和 Codex 三秒上限的工程。 */ + const missingRoot = await createProject({ + hooks: [{ id: 'session-end', definition: `{ event: 'SessionEnd', timeout: 4, platforms: { 'claude-code': { timeout: 60 } }, run() {} }` }], + hooksOptions: `{ include: ['session-end', 'missing'] }`, + }); + /** discover 与 validate 阶段应分别提交目标明确的诊断。 */ + const missing = await runProject({ cwd: missingRoot, command: 'validate', mode: 'production' }); + expect(missing.success).toBe(false); + expect(missing.diagnostics).toContainEqual(expect.objectContaining({ code: 'HOOK_INCLUDE_MISSING' })); + expect(missing.diagnostics).not.toContainEqual(expect.objectContaining({ code: 'HOOK_TIMEOUT_PLATFORM_LIMIT' })); + + /** Codex 使用三秒,而 Claude Code 单独超过六十秒上限的工程。 */ + const claudeRoot = await createProject({ + hooks: [{ id: 'session-end', definition: `{ event: 'SessionEnd', timeout: 3, platforms: { 'claude-code': { timeout: 61 } }, run() {} }` }], + }); + /** Claude Code 上限必须独立于 Codex 默认值验证。 */ + const claude = await runProject({ cwd: claudeRoot, command: 'validate', mode: 'production' }); + expect(claude.success).toBe(false); + expect(claude.diagnostics).toContainEqual(expect.objectContaining({ code: 'HOOK_TIMEOUT_PLATFORM_LIMIT' })); + }); +}); diff --git a/packages/extensions/hooks/test/build.test.ts b/packages/extensions/hooks/test/build.test.ts new file mode 100644 index 0000000..113b594 --- /dev/null +++ b/packages/extensions/hooks/test/build.test.ts @@ -0,0 +1,119 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { HOOK_EVENTS } from '../src/index.js'; +import { canonicalHooks, createProject, runHandler, runProject } from './fixture.js'; + +describe('Hooks Extension build', () => { + it('builds all canonical events once and adapts them to Claude Code and Codex', async () => { + /** 覆盖完整事件矩阵和本地第三方依赖的真实工程。 */ + const root = await createProject({ hooks: canonicalHooks(), dependency: true }); + /** 完整提交双 Platform 产物的构建结果。 */ + const result = await runProject({ cwd: root, command: 'build', mode: 'production' }); + + expect(result.success).toBe(true); + /** event 表示当前规范事件,用于验证两个 Contributor 都报告原生触发能力。 */ + for (const event of HOOK_EVENTS) { + expect(result.compatibility).toContainEqual(expect.objectContaining({ + platform: 'claude-code', + capability: `event.${event.replace(/([a-z0-9])([A-Z])/gu, '$1-$2').toLowerCase()}`, + level: 'native', + })); + expect(result.compatibility).toContainEqual(expect.objectContaining({ + platform: 'codex', + capability: `event.${event.replace(/([a-z0-9])([A-Z])/gu, '$1-$2').toLowerCase()}`, + level: 'native', + })); + } + expect(result.compatibility).toContainEqual(expect.objectContaining({ + platform: 'codex', + subject: 'hook:stop', + capability: 'matcher', + level: 'degraded', + })); + + /** Claude Code 最终 Plugin Manifest。 */ + const claudeManifest = JSON.parse(await fs.readFile( + path.join(root, 'dist/claude-code/plugin/.claude-plugin/plugin.json'), + 'utf8', + )) as Record; + /** Codex 最终 Plugin Manifest。 */ + const codexManifest = JSON.parse(await fs.readFile( + path.join(root, 'dist/codex/plugin/.codex-plugin/plugin.json'), + 'utf8', + )) as Record; + expect(claudeManifest.hooks).toBe('./hooks/hooks.json'); + expect(codexManifest.hooks).toBe('./hooks/hooks.json'); + + /** Claude Code Contributor 生成的 Hook 配置。 */ + const claudeHooks = JSON.parse(await fs.readFile( + path.join(root, 'dist/claude-code/plugin/hooks/hooks.json'), + 'utf8', + )) as { hooks: Record[] }[]> }; + /** Codex Contributor 生成的 Hook 配置。 */ + const codexHooks = JSON.parse(await fs.readFile( + path.join(root, 'dist/codex/plugin/hooks/hooks.json'), + 'utf8', + )) as { hooks: Record[] }[]> }; + expect(Object.keys(claudeHooks.hooks).sort()).toEqual([...HOOK_EVENTS].sort()); + expect(Object.keys(codexHooks.hooks).sort()).toEqual([...HOOK_EVENTS].sort()); + expect(claudeHooks.hooks.PreToolUse![0]!.hooks[0]).toMatchObject({ + type: 'command', + command: 'node', + args: ['${CLAUDE_PLUGIN_ROOT}/hooks/pre-tool-use/handler.mjs', 'claude-code'], + timeout: 5, + }); + expect(codexHooks.hooks.PreToolUse![0]!.hooks[0]).toMatchObject({ + type: 'command', + command: 'node "${PLUGIN_ROOT}/hooks/pre-tool-use/handler.mjs" codex', + timeout: 5, + additionalContextLimit: 1200, + }); + expect(codexHooks.hooks.PreToolUse![0]!.hooks[0]).not.toHaveProperty('args'); + + /** 两个平台复用同一平台中立 Handler 的 Claude Code 文件。 */ + const claudeHandler = path.join(root, 'dist/claude-code/plugin/hooks/pre-tool-use/handler.mjs'); + /** 两个平台复用同一平台中立 Handler 的 Codex 文件。 */ + const codexHandler = path.join(root, 'dist/codex/plugin/hooks/pre-tool-use/handler.mjs'); + expect(await fs.readFile(claudeHandler)).toEqual(await fs.readFile(codexHandler)); + await expect(fs.access(path.join(root, 'dist/claude-code/plugin/hooks/pre-tool-use/wire.mjs'))).rejects.toThrow(); + expect((await fs.readFile(claudeHandler, 'utf8'))).not.toContain('./wire.mjs'); + expect(await fs.readFile( + path.join(root, 'dist/claude-code/plugin/hooks/pre-tool-use/THIRD_PARTY_LICENSES.txt'), + 'utf8', + )).toContain('fixture-dependency@2.3.4'); + }); + + it('keeps Handler bytes and report hashes stable across isolated work directories', async () => { + /** 单个 Hook 足以暴露随机 Extension workDir 曾进入 Rolldown region 注释的问题。 */ + const root = await createProject({ + hooks: [{ id: 'session-start', definition: `{ event: 'SessionStart', run() {} }` }], + }); + /** 第一次完整构建的稳定报告。 */ + const first = await runProject({ cwd: root, command: 'build', mode: 'production' }); + /** 第一次事务提交后的 Handler 原始字节。 */ + const firstHandler = await fs.readFile(path.join(root, 'dist/claude-code/plugin/hooks/session-start/handler.mjs')); + /** 相同输入下由新 workDir 完成的第二次构建报告。 */ + const second = await runProject({ cwd: root, command: 'build', mode: 'production' }); + /** 第二次事务提交后的 Handler 原始字节。 */ + const secondHandler = await fs.readFile(path.join(root, 'dist/claude-code/plugin/hooks/session-start/handler.mjs')); + + expect(first.success).toBe(true); + expect(second.success).toBe(true); + expect(secondHandler).toEqual(firstHandler); + expect(secondHandler.toString('utf8')).not.toMatch(/^\/\/#(?:end)?region/mu); + expect(secondHandler.toString('utf8')).not.toContain(root); + expect(secondHandler.toString('utf8')).not.toContain('src/hooks/session-start/hook.ts'); + expect(second.packages).toEqual(first.packages); + /** 删除 Canonical 源码后,自包含安装产物仍必须可独立执行。 */ + await fs.rm(path.join(root, 'src/hooks'), { recursive: true }); + /** 删除源码后执行已安装 Handler 的进程结果。 */ + const execution = await runHandler( + path.join(root, 'dist/claude-code/plugin/hooks/session-start/handler.mjs'), + 'claude-code', + JSON.stringify({ session_id: 'session-1', cwd: root, hook_event_name: 'SessionStart', source: 'startup' }), + { CLAUDE_PLUGIN_ROOT: '/plugin-root', CLAUDE_PLUGIN_DATA: '/plugin-data' }, + ); + expect(execution).toEqual({ code: 0, stdout: '', stderr: '' }); + }); +}); diff --git a/packages/extensions/hooks/test/contributors.test.ts b/packages/extensions/hooks/test/contributors.test.ts new file mode 100644 index 0000000..f6c312e --- /dev/null +++ b/packages/extensions/hooks/test/contributors.test.ts @@ -0,0 +1,152 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { createProject, runProject } from './fixture.js'; + +describe('Hooks Extension contributors', () => { + it('rejects raw platform handler declarations and invalid contributor fields before bundling', async () => { + /** 同时尝试六类禁止入口和一个未知平台字段的恶意作者工程。 */ + const root = await createProject({ + hooks: [{ + id: 'unsafe', + definition: `{ + event: 'PreToolUse', + type: 'http', + command: 'rm -rf /', + executable: '/usr/bin/node', + url: 'https://example.com/hook', + prompt: 'approve', + agent: 'reviewer', + server: 'mcp-server', + tool: 'check', + platforms: { codex: { command: 'node unsafe.js' } }, + run() {}, + }`, + }], + }); + /** validate 在 Extension build 前收集的结构化失败结果。 */ + const result = await runProject({ cwd: root, command: 'validate', mode: 'production' }); + + expect(result.success).toBe(false); + expect(result.packages.length).toBeGreaterThan(0); + expect(result.diagnostics.filter(diagnostic => diagnostic.code === 'HOOK_FIELD_UNKNOWN')).toHaveLength(8); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ + code: 'HOOK_PLATFORM_FIELD_UNKNOWN', + })); + }); + + it('routes platform-only events exclusively to their declared configured Contributor', async () => { + /** Claude Code Setup 平台事件仍同时配置默认双 Platform 的工程。 */ + const root = await createProject({ + hooks: [{ + id: 'setup', + definition: `{ event: { platform: 'claude-code', name: 'Setup' }, matcher: 'init', run() {} }`, + }], + }); + /** 平台事件成功构建后的兼容性和 Asset 结果。 */ + const result = await runProject({ cwd: root, command: 'build', mode: 'production' }); + + expect(result.success).toBe(true); + expect(result.compatibility).toContainEqual(expect.objectContaining({ + platform: 'claude-code', + subject: 'hook:setup', + capability: 'event.setup', + level: 'native', + })); + expect(result.compatibility).toContainEqual(expect.objectContaining({ + platform: 'codex', + subject: 'hook:setup', + level: 'unsupported', + })); + await expect(fs.access(path.join(root, 'dist/codex/plugin/hooks/hooks.json'))).rejects.toThrow(); + /** Codex Manifest 不应因其他平台事件获得空 hooks 字段。 */ + const codexManifest = JSON.parse(await fs.readFile( + path.join(root, 'dist/codex/plugin/.codex-plugin/plugin.json'), + 'utf8', + )) as Record; + expect(codexManifest).not.toHaveProperty('hooks'); + }); + + it('keeps an empty Extension asset-free and uses the Cursor Contributor when selected', async () => { + /** 没有 `src/hooks` 的空 Extension 工程。 */ + const emptyRoot = await createProject(); + /** 空 Extension 的成功构建结果。 */ + const empty = await runProject({ cwd: emptyRoot, command: 'build', mode: 'production' }); + expect(empty.success).toBe(true); + expect(empty.packages + .flatMap(unit => unit.assets) + .some(asset => asset.path.startsWith('hooks/'))).toBe(false); + + /** 只配置 Cursor、且拥有实际 Hook 资源的工程。 */ + const cursorRoot = await createProject({ + hooks: [{ id: 'stop', definition: `{ event: 'Stop', run() {} }` }], + configImports: `import cursor from '@tokenroll/acplugin-platform-cursor';`, + configFields: 'platforms: [cursor({ strict: false })], build: { strict: false },', + }); + /** relaxed 模式使用 Cursor 事件映射并保留 transform 结论。 */ + const cursorResult = await runProject({ cwd: cursorRoot, command: 'validate', mode: 'production' }); + expect(cursorResult.success).toBe(true); + expect(cursorResult.compatibility).toContainEqual(expect.objectContaining({ + platform: 'cursor', + subject: 'hook:stop', + level: 'transform', + })); + }); + + it('applies strictness only when an actual Codex matcher loses semantics', async () => { + /** Stop 使用有语义 matcher 的严格构建工程。 */ + const root = await createProject({ + hooks: [{ id: 'stop', definition: `{ event: 'Stop', matcher: 'quality-gate', run() {} }` }], + configFields: 'platforms: [claudeCode(), codex()], build: { strict: true },', + }); + /** strict 模式因当前 Hook 的 Codex matcher 损失而失败。 */ + const strict = await runProject({ cwd: root, command: 'validate', mode: 'production' }); + expect(strict.success).toBe(false); + expect(strict.diagnostics).toContainEqual(expect.objectContaining({ + code: 'COMPATIBILITY_STRICT_FAILURE', + platform: 'codex', + })); + }); + + it('reports Claude Code events that silently ignore meaningful matchers', async () => { + /** 只配置 Claude Code,避免其他 Platform 的兼容性结论干扰断言。 */ + const root = await createProject({ + hooks: [{ id: 'stop', definition: `{ event: 'Stop', matcher: 'quality-gate', run() {} }` }], + configImports: `import claudeCode from '@tokenroll/acplugin-platform-claude-code';`, + configFields: 'platforms: [claudeCode()], build: { strict: true },', + }); + /** meaningful matcher 被宿主静默忽略,因此严格模式必须失败。 */ + const strict = await runProject({ cwd: root, command: 'validate', mode: 'production' }); + expect(strict.success).toBe(false); + expect(strict.diagnostics).toContainEqual(expect.objectContaining({ + code: 'COMPATIBILITY_STRICT_FAILURE', + platform: 'claude-code', + })); + }, 15_000); + + it('rejects unconfigured and unknown platform-only events with targeted diagnostics', async () => { + /** 只配置 Codex 却声明 Claude Code Setup 的工程。 */ + const unconfiguredRoot = await createProject({ + hooks: [{ + id: 'setup', + definition: `{ event: { platform: 'claude-code', name: 'Setup' }, run() {} }`, + }], + configImports: `import codex from '@tokenroll/acplugin-platform-codex';`, + configFields: 'platforms: [codex({ strict: false })], build: { strict: false },', + }); + /** Platform 缺失应在 Bundle 前失败。 */ + const unconfigured = await runProject({ cwd: unconfiguredRoot, command: 'validate', mode: 'production' }); + expect(unconfigured.diagnostics).toContainEqual(expect.objectContaining({ code: 'HOOK_PLATFORM_NOT_CONFIGURED' })); + + /** 默认包含 Claude Code、但事件名不属于其官方 Schema 的工程。 */ + const unknownRoot = await createProject({ + hooks: [{ + id: 'unknown-event', + definition: `{ event: { platform: 'claude-code', name: 'ImaginaryEvent' }, run() {} }`, + }], + }); + /** Contributor 未知事件应给出独立诊断码。 */ + const unknown = await runProject({ cwd: unknownRoot, command: 'validate', mode: 'production' }); + expect(unknown.diagnostics).toContainEqual(expect.objectContaining({ code: 'HOOK_PLATFORM_EVENT_UNSUPPORTED' })); + }, 15_000); +}); diff --git a/packages/extensions/hooks/test/fixture.ts b/packages/extensions/hooks/test/fixture.ts new file mode 100644 index 0000000..0ebee77 --- /dev/null +++ b/packages/extensions/hooks/test/fixture.ts @@ -0,0 +1,348 @@ +import { spawn } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach } from 'vitest'; +import type { BuildReport, RunProjectOptions } from '@tokenroll/acplugin'; +import { HOOK_EVENTS } from '../src/index.js'; + +/** 当前测试文件所在仓库的绝对根目录。 */ +const repositoryRoot = path.resolve(import.meta.dirname, '../../../..'); + +/** 测试描述文件通过临时包入口加载的 Hooks Extension 构建产物。 */ +const extensionEntry = path.join(repositoryRoot, 'packages/extensions/hooks/dist/index.mjs'); + +/** 需要自定义 Platform 时由配置文件直接加载的主包构建产物。 */ +const acpluginEntry = path.join(repositoryRoot, 'packages/acplugin/dist/index.mjs'); + +/** 配置覆盖使用的三个独立 Platform 真实构建入口。 */ +const claudeCodeEntry = path.join(repositoryRoot, 'packages/platforms/claude-code/dist/index.mjs'); +/** Hook 测试自定义配置使用的 Codex Platform 构建入口。 */ +const codexEntry = path.join(repositoryRoot, 'packages/platforms/codex/dist/index.mjs'); +/** Hook 测试自定义配置使用的 Cursor Platform 构建入口。 */ +const cursorEntry = path.join(repositoryRoot, 'packages/platforms/cursor/dist/index.mjs'); + +/** 当前测试创建并在 afterEach 中统一删除的临时工程。 */ +const temporaryRoots: string[] = []; + +/** 单个测试 Hook 的目录 ID 和 plain descriptor 源码。 */ +interface HookFixture { + /** `src/hooks/` 使用的规范目录 ID。 */ + readonly id: string; + /** descriptor 之前写入的可选额外 import。 */ + readonly imports?: string; + /** 默认导出的 TypeScript 对象表达式。 */ + readonly definition: string; +} + +/** 创建临时规范工程时使用的可选配置。 */ +interface ProjectFixtureOptions { + /** 当前工程需要写入的 Hook 作者资源。 */ + readonly hooks?: readonly HookFixture[]; + /** 添加到配置文件 import 区域的源码。 */ + readonly configImports?: string; + /** 添加到顶层配置对象的字段源码。 */ + readonly configFields?: string; + /** 是否提供一个带 LICENSE 的本地第三方依赖。 */ + readonly dependency?: boolean; + /** 直接传入 `hooks(...)` 的可选 TypeScript 参数表达式。 */ + readonly hooksOptions?: string; +} + +/** 子进程 Handler 的稳定退出状态和有限输出。 */ +interface HandlerResult { + /** Node 子进程退出码。 */ + readonly code: number | null; + /** Handler 写入标准输出的完整文本。 */ + readonly stdout: string; + /** Handler 写入标准错误的安全文本。 */ + readonly stderr: string; +} + +/** + * 在原生 Node ESM 子进程中运行公开 API,确保共享 registry brand 只绑定一个主包实例。 + * + * @param options 可 JSON 序列化的项目运行选项。 + * @returns 公开 API 产生的结构化 BuildReport。 + */ +export async function runProject(options: RunProjectOptions): Promise { + /** 子进程直接导入真实主包构建产物并序列化结果的 ESM 源码。 */ + const source = ` +import { runProject } from ${JSON.stringify(acpluginEntry)}; +try { + const result = await runProject(${JSON.stringify(options)}); + process.stdout.write(JSON.stringify({ ok: true, result })); +} catch (error) { + process.stdout.write(JSON.stringify({ + ok: false, + name: error instanceof Error ? error.name : 'Error', + message: error instanceof Error ? error.message : 'Project execution failed.', + diagnostics: error && typeof error === 'object' && 'diagnostics' in error ? error.diagnostics : [], + })); +} +`; + /** Node 子进程的退出状态与文本输出。 */ + const execution = await new Promise((resolve, reject) => { + /** 不经过 Vitest 转换器的原生 ESM 子进程。 */ + const child = spawn(process.execPath, ['--input-type=module', '--eval', source], { + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + /** 子进程累计的 JSON 标准输出。 */ + let stdout = ''; + /** 子进程累计的框架错误输出。 */ + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + child.on('error', reject); + child.on('close', code => resolve({ code, stdout, stderr })); + }); + if (execution.code !== 0) + throw new Error(`Project subprocess failed: ${execution.stderr}`); + /** 子进程返回的成功结果或安全异常摘要。 */ + const payload = JSON.parse(execution.stdout) as { + readonly ok: boolean; + readonly result?: BuildReport; + readonly name?: string; + readonly message?: string; + readonly diagnostics?: unknown; + }; + if (!payload.ok || payload.result === undefined) + throw new Error(`${payload.name ?? 'Error'}: ${payload.message ?? 'Project execution failed.'} ${JSON.stringify(payload.diagnostics ?? [])} STDERR=${execution.stderr}`); + return payload.result; +} + +/** + * 在临时工程中创建可由统一 Module Service 和 Rolldown 共同解析的 Extension 包入口。 + * + * @param root 临时工程根目录。 + */ +async function writeExtensionProxy(root: string): Promise { + /** 临时 node_modules 中的 Hooks Extension 包目录。 */ + const packageRoot = path.join(root, 'node_modules/@tokenroll/acplugin-extension-hooks'); + await fs.mkdir(packageRoot, { recursive: true }); + await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ + name: '@tokenroll/acplugin-extension-hooks', + version: '1.0.0', + type: 'module', + exports: './index.mjs', + })); + await fs.writeFile( + path.join(packageRoot, 'index.mjs'), + `export * from ${JSON.stringify(extensionEntry)}; export { default } from ${JSON.stringify(extensionEntry)};\n`, + ); + /** Hooks 构建产物按包名导入公开 SDK,这里提供与打包安装相同的代理入口。 */ + const acpluginRoot = path.join(root, 'node_modules/@tokenroll/acplugin'); + await fs.mkdir(acpluginRoot, { recursive: true }); + await fs.writeFile(path.join(acpluginRoot, 'package.json'), JSON.stringify({ + name: '@tokenroll/acplugin', + version: '1.0.0', + type: 'module', + exports: { '.': './index.mjs', './sdk': './sdk.mjs' }, + })); + await fs.writeFile(path.join(acpluginRoot, 'index.mjs'), `export * from ${JSON.stringify(acpluginEntry)};\n`); + await fs.writeFile(path.join(acpluginRoot, 'sdk.mjs'), `export * from ${JSON.stringify(path.join(repositoryRoot, 'packages/acplugin/dist/sdk.mjs'))};\n`); + /** Platform package proxies keep config imports inside the fixture's package graph. */ + for (const [name, entry] of [ + ['@tokenroll/acplugin-platform-claude-code', claudeCodeEntry], + ['@tokenroll/acplugin-platform-codex', codexEntry], + ['@tokenroll/acplugin-platform-cursor', cursorEntry], + ] as const) { + /** 当前代理包的物理根目录。 */ + const platformRoot = path.join(root, 'node_modules', name); + await fs.mkdir(platformRoot, { recursive: true }); + await fs.writeFile(path.join(platformRoot, 'package.json'), JSON.stringify({ + name, + version: '1.0.0', + type: 'module', + exports: './index.mjs', + })); + await fs.writeFile(path.join(platformRoot, 'index.mjs'), `export * from ${JSON.stringify(entry)}; export { default } from ${JSON.stringify(entry)};\n`); + } +} + +/** + * 写入一个实际参与 Bundle 和第三方许可收集的本地 npm 依赖。 + * + * @param root 临时工程根目录。 + */ +async function writeLicensedDependency(root: string): Promise { + /** 临时 node_modules 中的第三方测试包目录。 */ + const packageRoot = path.join(root, 'node_modules/fixture-dependency'); + await fs.mkdir(packageRoot, { recursive: true }); + await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ + name: 'fixture-dependency', + version: '2.3.4', + type: 'module', + exports: './index.js', + license: 'MIT', + })); + await fs.writeFile(path.join(packageRoot, 'index.js'), 'export function dependencyMessage() { return "licensed dependency"; }\n'); + await fs.writeFile(path.join(packageRoot, 'LICENSE'), 'Fixture dependency license.\n'); +} + +/** + * 创建带最小 Skill、配置和可选 Hooks 的真实临时工程。 + * + * @param options Hook、Platform 字段和第三方依赖选项。 + * @returns 已登记清理的工程绝对路径。 + */ +export async function createProject(options: ProjectFixtureOptions = {}): Promise { + /** 当前测试独占的临时工程根目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-hooks-test-')); + temporaryRoots.push(root); + await writeExtensionProxy(root); + if (options.dependency === true) + await writeLicensedDependency(root); + await fs.mkdir(path.join(root, 'src/skills/hello'), { recursive: true }); + await fs.writeFile( + path.join(root, 'src/skills/hello/SKILL.md'), + '---\ndescription: Say hello.\n---\nSay hello to the user.\n', + ); + for (const hook of options.hooks ?? []) { + /** 当前 Hook 的规范一级目录。 */ + const directory = path.join(root, 'src/hooks', hook.id); + await fs.mkdir(directory, { recursive: true }); + await fs.writeFile( + path.join(directory, 'hook.ts'), + `import type { Hook } from '@tokenroll/acplugin-extension-hooks';\n${hook.imports ?? ''}\nexport default ${hook.definition} satisfies Hook;\n`, + ); + } + await fs.writeFile(path.join(root, 'acplugin.config.ts'), ` +import hooks from '@tokenroll/acplugin-extension-hooks'; +${options.configImports ?? `import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex';`} +export default { + name: 'hooks-fixture', + version: '1.0.0', + description: 'Hooks integration fixture.', + extensions: [hooks(${options.hooksOptions ?? ''})], + ${options.configFields ?? 'platforms: [claudeCode(), codex()], build: { strict: false },'} +}; +`); + return root; +} + +/** + * 执行最终 Bundle Handler,并完整收集测试所需的 stdout 和 stderr。 + * + * @param handler Handler Bundle 绝对路径。 + * @param platform Contributor 固定传入的 Platform ID。 + * @param input 写入 stdin 的原始字符串。 + * @param environment 可选的 Plugin Root 和 Plugin Data 环境变量。 + * @returns 子进程退出结果。 + */ +export async function runHandler( + handler: string, + platform: string, + input: string, + environment: Readonly> = {}, +): Promise { + return new Promise((resolve, reject) => { + /** 使用当前 Node 执行实际安装产物的子进程。 */ + const child = spawn(process.execPath, [handler, platform], { + env: { ...process.env, ...environment }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + /** 子进程累计的标准输出文本。 */ + let stdout = ''; + /** 子进程累计的标准错误文本。 */ + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + child.on('error', reject); + child.on('close', code => resolve({ code, stdout, stderr })); + child.stdin.end(input); + }); +} + +/** + * 创建覆盖 11 个规范事件、输入规范化和安全输出边界的 Hook fixtures。 + * + * @returns 按事件声明顺序排列的作者资源。 + */ +export function canonicalHooks(): readonly HookFixture[] { + return HOOK_EVENTS.map((event): HookFixture => { + if (event === 'PreToolUse') { + return { + id: 'pre-tool-use', + imports: `import { dependencyMessage } from 'fixture-dependency';`, + definition: `{ + event: 'PreToolUse', + matcher: 'Bash', + timeout: 5, + platforms: { codex: { additionalContextLimit: 1200 } }, + run(input, context) { + const toolInput = input.toolInput as { nestedValue: string }; + dependencyMessage(); + if (toolInput.nestedValue === 'invalid-json') + return { decision: 'allow', updatedInput: { nested: { secret: BigInt(1) } } }; + if (toolInput.nestedValue === 'context') + return { decision: 'deny', reason: [context.platform, context.pluginRoot, context.pluginData].join(':') }; + return { decision: 'deny', reason: context.platform + ':' + input.toolName + ':' + toolInput.nestedValue }; + }, + }`, + }; + } + if (event === 'SessionEnd') { + return { + id: 'session-end', + imports: `import { readFile } from 'node:fs';`, + definition: `{ + event: 'SessionEnd', + run(input) { + if (input.reason === 'oversized') return { systemMessage: 'x'.repeat(1024 * 1024) }; + if (input.reason.startsWith('log:')) process.stdout.write(input.reason.slice(4)); + if (input.reason.startsWith('throw:')) throw new Error(input.reason.slice(6)); + if (input.reason === 'delayed-log') setTimeout(() => process.stdout.write('DELAYED_SECRET'), 0); + if (input.reason === 'delayed-throw') setTimeout(() => { throw new Error('DELAYED_SECRET'); }, 0); + if (input.reason === 'delayed-rejection') setTimeout(() => Promise.reject(new Error('DELAYED_SECRET')), 0); + if (input.reason === 'multiple-async-failures') { + setTimeout(() => { throw new Error('FIRST_SECRET'); }, 0); + setTimeout(() => { throw new Error('SECOND_SECRET'); }, 5); + } + if (input.reason === 'late-before-exit') + process.once('beforeExit', () => { throw new Error('BEFORE_EXIT_SECRET'); }); + if (input.reason === 'late-before-exit-io') + process.once('beforeExit', () => { + readFile(new URL(import.meta.url), () => { throw new Error('BEFORE_EXIT_IO_SECRET'); }); + }); + }, + }`, + }; + } + if (event === 'Stop') { + return { + id: 'stop', + definition: `{ event: 'Stop', matcher: 'quality-gate', run() { return { decision: 'finish' }; } }`, + }; + } + if (event === 'PreCompact' || event === 'PostCompact') { + /** 两个压缩事件共同验证通用 continue/stop wire 语义。 */ + const id = event === 'PreCompact' ? 'pre-compact' : 'post-compact'; + return { + id, + definition: `{ event: ${JSON.stringify(event)}, run() { return { decision: 'stop', reason: 'Compact later.' }; } }`, + }; + } + /** 其他规范事件只需证明发现、Bundle、Contributor 和兼容性闭环。 */ + const id = event.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); + return { id, definition: `{ event: ${JSON.stringify(event)}, run() {} }` }; + }); +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); diff --git a/packages/extensions/hooks/test/hooks.types.ts b/packages/extensions/hooks/test/hooks.types.ts new file mode 100644 index 0000000..fbe5a60 --- /dev/null +++ b/packages/extensions/hooks/test/hooks.types.ts @@ -0,0 +1,64 @@ +import type { Hook } from '../src/index.js'; + +/** PreToolUse 定义用于验证事件级输入和结果推断。 */ +const preToolUse = { + event: 'PreToolUse', + /** 类型检查同时确认运行时上下文使用最终 platform 术语。 */ + run(input, context) { + /** 事件判别后可直接读取 PreToolUse 专属输入。 */ + const toolName: string = input.toolName; + /** 所有 Hook 上下文统一暴露当前目标 Platform。 */ + const platform: string = context.platform; + return { + decision: 'allow' as const, + reason: `${platform}:${toolName}`, + updatedInput: { command: 'pnpm test' }, + }; + }, +} satisfies Hook<'PreToolUse'>; +void preToolUse; + +/** Claude Code 专属事件必须使用显式 Platform 对象。 */ +const platformHook = { + event: { platform: 'claude-code', name: 'Setup' } as const, + /** Platform 专属事件输入保留结构化事件判别值。 */ + run(input) { + /** 收窄后的事件能够读取固定 Platform ID。 */ + const platform: string = input.event.platform; + return { systemMessage: platform }; + }, +} satisfies Hook<{ readonly platform: 'claude-code'; readonly name: 'Setup' }>; +void platformHook; + +/** advisory 事件返回控制流决策时必须触发类型错误。 */ +const invalidSessionEnd = { + event: 'SessionEnd', + // @ts-expect-error SessionEnd 是 advisory 事件,不能声明控制流决策。 + run: () => ({ decision: 'stop' }), +} satisfies Hook<'SessionEnd'>; +void invalidSessionEnd; + +/** 非规范 PreToolUse decision 必须触发类型错误。 */ +const invalidPreToolUse = { + event: 'PreToolUse', + // @ts-expect-error PreToolUse 只接受 allow 或 deny 规范决策。 + run: () => ({ decision: 'block' }), +} satisfies Hook<'PreToolUse'>; +void invalidPreToolUse; + +/** Stop 返回其他事件专属字段时必须触发类型错误。 */ +const invalidStop = { + event: 'Stop', + // @ts-expect-error Stop 不允许返回 PreToolUse 的 updatedInput 字段。 + run: () => ({ updatedInput: { command: 'unsafe' } }), +} satisfies Hook<'Stop'>; +void invalidStop; + +/** 未注册的裸事件名称不能扩展规范事件联合。 */ +const invalidEvent = { + // @ts-expect-error 非规范事件不能作为裸字符串扩入联合类型。 + event: 'Setup', + /** 无效事件仍提供最小函数以隔离 event 字段错误。 */ + run() {}, +} satisfies Hook; +void invalidEvent; diff --git a/packages/extensions/hooks/test/protocol.test.ts b/packages/extensions/hooks/test/protocol.test.ts new file mode 100644 index 0000000..df0ef29 --- /dev/null +++ b/packages/extensions/hooks/test/protocol.test.ts @@ -0,0 +1,216 @@ +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { canonicalHooks, createProject, runHandler, runProject } from './fixture.js'; + +describe('Hooks Extension protocol', () => { + it('normalizes input, maps results, bounds I/O, and never exposes handler failures', async () => { + /** 复用完整事件 fixture 取得真实构建后的 Handler。 */ + const root = await createProject({ hooks: canonicalHooks(), dependency: true }); + /** 生成两个默认 Platform Handler 的构建结果。 */ + const build = await runProject({ cwd: root, command: 'build', mode: 'production' }); + expect(build.success).toBe(true); + /** 用于验证 camelCase 和 PreToolUse deny 映射的 Handler。 */ + const preToolHandler = path.join(root, 'dist/claude-code/plugin/hooks/pre-tool-use/handler.mjs'); + /** 平台发送给 Handler 的规范 snake_case 输入。 */ + const preToolInput = JSON.stringify({ + session_id: 'session-1', + transcript_path: null, + cwd: root, + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { nested_value: 'normalized' }, + tool_use_id: 'tool-1', + }); + /** Claude Code 参数下的真实 Runner 输出。 */ + const claude = await runHandler(preToolHandler, 'claude-code', preToolInput, { + CLAUDE_PLUGIN_ROOT: '/plugin-root', + CLAUDE_PLUGIN_DATA: '/plugin-data', + }); + expect(claude.code).toBe(0); + expect(JSON.parse(claude.stdout)).toEqual({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: 'claude-code:Bash:normalized', + }, + }); + expect(claude.stderr).toBe(''); + + /** 两个 wire profile 必须各自解析平台原生的 Plugin 根和数据目录。 */ + const contextInput = JSON.stringify({ + session_id: 'session-1', + cwd: root, + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { nested_value: 'context' }, + tool_use_id: 'tool-2', + }); + /** Claude Code wire 的运行时上下文结果。 */ + const claudeContext = await runHandler(preToolHandler, 'claude-code', contextInput, { + CLAUDE_PLUGIN_ROOT: '/claude-root', + CLAUDE_PLUGIN_DATA: '/claude-data', + }); + /** Codex wire 的运行时上下文结果。 */ + const codexContext = await runHandler( + path.join(root, 'dist/codex/plugin/hooks/pre-tool-use/handler.mjs'), + 'codex', + contextInput, + { PLUGIN_ROOT: '/codex-root', PLUGIN_DATA: '/codex-data' }, + ); + expect(JSON.parse(claudeContext.stdout).hookSpecificOutput.permissionDecisionReason) + .toBe('claude-code:/claude-root:/claude-data'); + expect(JSON.parse(codexContext.stdout).hookSpecificOutput.permissionDecisionReason) + .toBe('codex:/codex-root:/codex-data'); + + /** SessionEnd Handler 用于触发三种安全失败边界。 */ + const sessionEndHandler = path.join(root, 'dist/claude-code/plugin/hooks/session-end/handler.mjs'); + /** Codex 目录中与 Codex wire profile 相邻的 SessionEnd Handler。 */ + const codexSessionEndHandler = path.join(root, 'dist/codex/plugin/hooks/session-end/handler.mjs'); + /** 生成 SessionEnd 输入的局部辅助函数。 */ + const sessionEndInput = (reason: string): string => JSON.stringify({ + session_id: 'session-1', + transcript_path: null, + cwd: root, + hook_event_name: 'SessionEnd', + reason, + }); + /** 超出一 MiB 的规范结果必须被 Runner 阻止。 */ + const oversized = await runHandler(sessionEndHandler, 'claude-code', sessionEndInput('oversized')); + expect(oversized).toEqual({ + code: 1, + stdout: '', + stderr: 'acplugin hook error: OUTPUT_TOO_LARGE\n', + }); + /** 用户实现直接写 stdout 时不得绕过规范结果协议或泄露内容。 */ + const logged = await runHandler(sessionEndHandler, 'claude-code', sessionEndInput('log:TOP_SECRET')); + expect(logged).toEqual({ + code: 1, + stdout: '', + stderr: 'acplugin hook error: HANDLER_OUTPUT_FORBIDDEN\n', + }); + expect(logged.stderr).not.toContain('TOP_SECRET'); + /** 用户异常消息只能收敛为稳定安全代码。 */ + const thrown = await runHandler(codexSessionEndHandler, 'codex', sessionEndInput('throw:TOP_SECRET')); + expect(thrown).toEqual({ + code: 1, + stdout: '', + stderr: 'acplugin hook error: HANDLER_FAILED\n', + }); + /** 超过输入上限时在 JSON 解析前返回固定错误。 */ + const tooLarge = await runHandler(codexSessionEndHandler, 'codex', `{"value":"${'x'.repeat(1024 * 1024)}"}`); + expect(tooLarge).toEqual({ + code: 1, + stdout: '', + stderr: 'acplugin hook error: INPUT_TOO_LARGE\n', + }); + /** Codex 目录中与 Codex wire profile 相邻的 PreToolUse Handler。 */ + const codexPreToolHandler = path.join(root, 'dist/codex/plugin/hooks/pre-tool-use/handler.mjs'); + /** 同一对象中的 snake_case/camelCase 字段碰撞不得静默覆盖。 */ + const collision = await runHandler(codexPreToolHandler, 'codex', JSON.stringify({ + session_id: 'session-1', + cwd: root, + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { nested_value: 'first', nestedValue: 'second' }, + tool_use_id: 'tool-1', + })); + expect(collision).toEqual({ + code: 1, + stdout: '', + stderr: 'acplugin hook error: INPUT_KEY_COLLISION\n', + }); + /** 构造超过递归规范化上限、但仍远小于字节上限的输入字段。 */ + let nestedInput: unknown = 'leaf'; + /** depth 表示当前追加的对象嵌套层数。 */ + for (let depth = 0; depth < 130; depth += 1) + nestedInput = { value: nestedInput }; + /** 过深输入必须使用稳定错误码终止,不能触发运行时栈错误。 */ + const tooDeep = await runHandler(codexPreToolHandler, 'codex', JSON.stringify({ + session_id: 'session-1', + cwd: root, + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: nestedInput, + tool_use_id: 'tool-1', + })); + expect(tooDeep).toEqual({ + code: 1, + stdout: '', + stderr: 'acplugin hook error: INPUT_TOO_DEEP\n', + }); + + /** updatedInput 的嵌套 BigInt 不是 JSON 值,必须在 wire 序列化前拒绝。 */ + const invalidUpdatedInput = await runHandler(codexPreToolHandler, 'codex', JSON.stringify({ + session_id: 'session-1', + cwd: root, + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { nested_value: 'invalid-json' }, + tool_use_id: 'tool-1', + })); + expect(invalidUpdatedInput).toEqual({ + code: 1, + stdout: '', + stderr: 'acplugin hook error: RESULT_UPDATED_INPUT_INVALID\n', + }); + + /** 未等待任务中的输出仍在进程退出前被拦截,且不会泄露原文。 */ + const delayedLog = await runHandler(sessionEndHandler, 'claude-code', sessionEndInput('delayed-log')); + expect(delayedLog).toEqual({ + code: 1, + stdout: '', + stderr: 'acplugin hook error: HANDLER_OUTPUT_FORBIDDEN\n', + }); + expect(delayedLog.stderr).not.toContain('DELAYED_SECRET'); + /** 未等待 timer 抛错和拒绝统一收敛为异步失败码。 */ + for (const reason of ['delayed-throw', 'delayed-rejection']) { + /** 当前异步失败形式的隔离执行结果。 */ + const delayedFailure = await runHandler(sessionEndHandler, 'claude-code', sessionEndInput(reason)); + expect(delayedFailure).toEqual({ + code: 1, + stdout: '', + stderr: 'acplugin hook error: HANDLER_ASYNC_FAILED\n', + }); + expect(delayedFailure.stderr).not.toContain('DELAYED_SECRET'); + } + /** 多个未等待异常以及 beforeExit 启动的同步或 I/O 异常都必须保持在安全监听边界内。 */ + for (const reason of ['multiple-async-failures', 'late-before-exit', 'late-before-exit-io']) { + /** 当前复杂异步失败形式的隔离执行结果。 */ + const complexFailure = await runHandler(sessionEndHandler, 'claude-code', sessionEndInput(reason)); + expect(complexFailure).toEqual({ + code: 1, + stdout: '', + stderr: 'acplugin hook error: HANDLER_ASYNC_FAILED\n', + }); + expect(complexFailure.stderr).not.toMatch(/FIRST_SECRET|SECOND_SECRET|BEFORE_EXIT_(?:IO_)?SECRET/u); + } + + /** 两个压缩事件都必须通过各自 Platform 的完整 Handler/wire 组合。 */ + for (const [event, id] of [['PreCompact', 'pre-compact'], ['PostCompact', 'post-compact']] as const) { + /** 当前压缩事件的原生输入。 */ + const compactInput = JSON.stringify({ + session_id: 'session-1', + transcript_path: null, + cwd: root, + hook_event_name: event, + trigger: 'manual', + }); + /** Claude Code 目录中的完整运行结果。 */ + const claudeCompact = await runHandler( + path.join(root, `dist/claude-code/plugin/hooks/${id}/handler.mjs`), + 'claude-code', + compactInput, + ); + /** Codex 目录中的完整运行结果。 */ + const codexCompact = await runHandler( + path.join(root, `dist/codex/plugin/hooks/${id}/handler.mjs`), + 'codex', + compactInput, + ); + expect(JSON.parse(claudeCompact.stdout)).toEqual(event === 'PreCompact' + ? { decision: 'block', reason: 'Compact later.' } + : { continue: false, stopReason: 'Compact later.' }); + expect(JSON.parse(codexCompact.stdout)).toEqual({ continue: false, stopReason: 'Compact later.' }); + } + }); +}); diff --git a/packages/extensions/hooks/tsconfig.json b/packages/extensions/hooks/tsconfig.json new file mode 100644 index 0000000..3ae4da2 --- /dev/null +++ b/packages/extensions/hooks/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/extensions/hooks/tsdown.config.ts b/packages/extensions/hooks/tsdown.config.ts new file mode 100644 index 0000000..4c38328 --- /dev/null +++ b/packages/extensions/hooks/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsdown'; + +/** Hooks Extension 骨架保持主包为 Peer Dependency。 */ +export default defineConfig({ + entry: ['./src/index.ts'], + format: ['esm'], + platform: 'node', + target: 'node20', + dts: { generator: 'oxc' }, + clean: true, + sourcemap: false, + deps: { neverBundle: ['@tokenroll/acplugin'] }, +}); diff --git a/packages/extensions/hooks/vitest.config.ts b/packages/extensions/hooks/vitest.config.ts new file mode 100644 index 0000000..384c212 --- /dev/null +++ b/packages/extensions/hooks/vitest.config.ts @@ -0,0 +1,22 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +/** Hooks 单测让公开主包与私有 Core 共享同一源码品牌实例。 */ +export default defineConfig({ + resolve: { + alias: [ + { + find: /^@tokenroll\/acplugin\/sdk$/, + replacement: fileURLToPath(new URL('../../acplugin/src/sdk.ts', import.meta.url)), + }, + { + find: /^@tokenroll\/acplugin$/, + replacement: fileURLToPath(new URL('../../acplugin/src/index.ts', import.meta.url)), + }, + { + find: /^@acplugin\/core$/, + replacement: fileURLToPath(new URL('../../core/src/index.ts', import.meta.url)), + }, + ], + }, +}); diff --git a/packages/extensions/mcp/CHANGELOG.md b/packages/extensions/mcp/CHANGELOG.md new file mode 100644 index 0000000..7327faa --- /dev/null +++ b/packages/extensions/mcp/CHANGELOG.md @@ -0,0 +1,18 @@ +# @tokenroll/acplugin-extension-mcp + +## 0.0.3-beta + +### Patch Changes + +- Updated dependencies + - @tokenroll/acplugin@0.0.3-beta + +## 0.0.2-beta + +### Major Changes + +- 889da32: Rewrite the MCP Extension around Core-owned portable-node compilation and execution, one shared stdio Bundle state, SDK-only Platform Contributors, deterministic HTTP/stdio transport configuration, real protocol smoke validation, and Core-managed Asset and third-party license delivery. + +### Patch Changes + +- Updated peer dependency on `@tokenroll/acplugin` to `^0.0.2-beta`. diff --git a/packages/extensions/mcp/LICENSE b/packages/extensions/mcp/LICENSE new file mode 100644 index 0000000..9113de7 --- /dev/null +++ b/packages/extensions/mcp/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 TokenRollAI + +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/packages/extensions/mcp/README.md b/packages/extensions/mcp/README.md new file mode 100644 index 0000000..a64d8f0 --- /dev/null +++ b/packages/extensions/mcp/README.md @@ -0,0 +1,69 @@ +# @tokenroll/acplugin-extension-mcp + +Optional MCP declarations, Core-managed local builds, and Platform Contributors for `@tokenroll/acplugin`. + +Requires Node.js `^20.19.0 || ^22.13.0 || >=23.5.0`. + +`可选的 MCP 远程声明、本地构建能力,以及面向各 ACPlugin Platform 的适配实现。` + +```bash +pnpm add -D @tokenroll/acplugin \ + @tokenroll/acplugin-platform-claude-code \ + @tokenroll/acplugin-extension-mcp +``` + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import mcp from '@tokenroll/acplugin-extension-mcp'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [claudeCode()], + extensions: [mcp()], +}); +``` + +Remote Streamable HTTP is declarative; provide only the endpoint and runtime secret references: + +```ts +// src/mcp/docs/mcp.ts +import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; + +export default { + transport: 'http', + url: 'https://example.com/mcp', + auth: { type: 'bearer', env: 'DOCS_TOKEN' }, + headers: { 'X-Tenant': { env: 'TENANT_ID' } }, +} satisfies McpServer; +``` + +Local stdio is executable content; provide a complete server implementation and reference its entry: + +```ts +// src/mcp/local-tools/mcp.ts +import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; + +export default { + transport: 'stdio', + entry: 'server.ts', + env: { API_TOKEN: { env: 'LOCAL_API_TOKEN' } }, +} satisfies McpServer; +``` + +The Extension asks Core's `portable-node` Compiler to bundle each local implementation once as Node 20 ESM and emit deterministic third-party notices when needed. It rejects unresolved runtime dynamic imports and runs the bundle through a bounded `initialize → initialized → tools/list` smoke test using only declared literal environment values. Referenced secret values are never read. Production remote endpoints require HTTPS; development permits loopback HTTP. + +`Extension 通过 Core portable-node Compiler 把本地实现统一构建一次 Node 20 ESM,并在需要时生成确定性的第三方许可材料。构建会拒绝无法解析的运行时动态导入,并仅使用声明的公开字面量环境值执行带超时和输出上限的 initialize → initialized → tools/list smoke;环境变量 Secret 引用值不会被读取。` + +| Transport | Claude Code | Codex | Cursor | Antigravity | OpenCode | Pi | +| --- | --- | --- | --- | --- | --- | --- | +| Remote HTTP | Native | Native | Native | Native | Native | Unsupported | +| Local stdio | Native | Native | Unsupported | Unsupported | Native | Unsupported | + +Unsupported transports are reported and never replaced with fabricated client behavior. Contracts were last rechecked on 2026-08-06 against [Claude Code MCP](https://code.claude.com/docs/en/mcp), [Codex MCP](https://learn.chatgpt.com/docs/extend/mcp), [Cursor MCP](https://cursor.com/docs/context/mcp), [Antigravity Plugins](https://antigravity.google/docs/plugins?app=cli), [OpenCode MCP](https://opencode.ai/docs/mcp-servers/), and [Pi Packages](https://pi.dev/docs/latest/packages). + +## License + +MIT diff --git a/packages/extensions/mcp/package.json b/packages/extensions/mcp/package.json new file mode 100644 index 0000000..aee6214 --- /dev/null +++ b/packages/extensions/mcp/package.json @@ -0,0 +1,51 @@ +{ + "name": "@tokenroll/acplugin-extension-mcp", + "version": "0.0.3-beta", + "description": "Portable MCP authoring and platform contributors for acplugin.", + "type": "module", + "license": "MIT", + "homepage": "https://github.com/TokenRollAI/acplugin#mcp-extension", + "repository": { + "type": "git", + "url": "git+https://github.com/TokenRollAI/acplugin.git", + "directory": "packages/extensions/mcp" + }, + "bugs": { + "url": "https://github.com/TokenRollAI/acplugin/issues" + }, + "sideEffects": false, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsdown", + "pretest": "pnpm --filter @acplugin/core run build && pnpm --filter @tokenroll/acplugin run build && pnpm --filter @tokenroll/acplugin-platform-claude-code run build && pnpm --filter @tokenroll/acplugin-platform-codex run build && pnpm run build", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc -p tsconfig.json" + }, + "peerDependencies": { + "@tokenroll/acplugin": "workspace:^" + }, + "devDependencies": { + "@modelcontextprotocol/sdk": "^1.30.0", + "@tokenroll/acplugin": "workspace:^", + "@types/node": "catalog:", + "@typescript/native": "catalog:", + "tsdown": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/extensions/mcp/src/build.ts b/packages/extensions/mcp/src/build.ts new file mode 100644 index 0000000..0047faf --- /dev/null +++ b/packages/extensions/mcp/src/build.ts @@ -0,0 +1,163 @@ +import type { ExtensionBuildContext, GeneratedAssetRef, PortableNodeCompileOptions } from '@tokenroll/acplugin/sdk'; +import type { DiscoveredMcpServers } from './discovery.js'; + +/** 当前固定 smoke 请求使用的 MCP 协议版本。 */ +const MCP_PROTOCOL_VERSION = '2025-11-25'; + +/** JSON-RPC/MCP 结构只接受普通对象。 */ +function isObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** JSON-RPC request/response ID 的稳定去重键。 */ +function rpcIdKey(value: unknown): string | undefined { + if (typeof value === 'string') return `string:${value}`; + if (typeof value === 'number' && Number.isFinite(value)) return `number:${value}`; + if (value === null) return 'null'; + return undefined; +} + +/** 验证一条 stdout 消息满足 JSON-RPC 2.0 request/notification/response envelope。 */ +function isJsonRpcMessage(value: unknown): value is Record { + if (!isObject(value) || value.jsonrpc !== '2.0') return false; + if (Object.hasOwn(value, 'method')) { + if (typeof value.method !== 'string' || value.method.length === 0) return false; + if (Object.hasOwn(value, 'id') && rpcIdKey(value.id) === undefined) return false; + return !Object.hasOwn(value, 'params') || isObject(value.params) || Array.isArray(value.params); + } + if (!Object.hasOwn(value, 'id') || rpcIdKey(value.id) === undefined) return false; + /** result 字段存在性用于约束 response 的二选一分支。 */ + const hasResult = Object.hasOwn(value, 'result'); + /** error 字段存在性与 result 必须恰好互斥。 */ + const hasError = Object.hasOwn(value, 'error'); + if (hasResult === hasError) return false; + if (!hasError) return true; + /** error payload 必须满足 JSON-RPC 稳定错误形状。 */ + const error = value.error; + return isObject(error) && Number.isInteger(error.code) && typeof error.message === 'string'; +} + +/** 验证 initialize 与 tools/list 的精确 MCP 结果形状。 */ +function validateMcpSmokeOutput(stdout: Uint8Array): boolean { + try { + /** stdio MCP 每行承载一条独立 JSON-RPC 消息。 */ + const lines = new TextDecoder().decode(stdout).split(/\r?\n/u).filter(line => line.length > 0); + /** request/response ID 在整个 smoke 输出中不能重复。 */ + const ids = new Set(); + /** 两个请求对应的唯一响应。 */ + let initialize: Record | undefined; + /** tools/list 请求对应的唯一响应。 */ + let tools: Record | undefined; + for (const line of lines) { + /** 单行 JSON 解析结果在读取字段前经过完整 envelope 校验。 */ + const message: unknown = JSON.parse(line); + if (!isJsonRpcMessage(message)) return false; + if (Object.hasOwn(message, 'id')) { + /** 规范化 ID 键防止字符串和数值碰撞或重复响应。 */ + const key = rpcIdKey(message.id)!; + if (ids.has(key)) return false; + ids.add(key); + } + /** 带 method 的消息是 Server request/notification,不是 smoke 响应。 */ + if (Object.hasOwn(message, 'method')) continue; + if (message.id === 1) initialize = message; + if (message.id === 2) tools = message; + } + if (initialize === undefined || tools === undefined || Object.hasOwn(initialize, 'error') || Object.hasOwn(tools, 'error')) return false; + /** initialize result 必须声明精确协议版本与 Server 身份。 */ + const initializeResult = initialize.result; + if (!isObject(initializeResult) + || initializeResult.protocolVersion !== MCP_PROTOCOL_VERSION + || !isObject(initializeResult.capabilities) + || !isObject(initializeResult.serverInfo) + || typeof initializeResult.serverInfo.name !== 'string' + || initializeResult.serverInfo.name.length === 0 + || typeof initializeResult.serverInfo.version !== 'string' + || initializeResult.serverInfo.version.length === 0) + return false; + /** tools/list result 必须提供可逐项验证的 tools 数组。 */ + const toolsResult = tools.result; + if (!isObject(toolsResult) || !Array.isArray(toolsResult.tools)) return false; + return toolsResult.tools.every(tool => isObject(tool) + && typeof tool.name === 'string' + && tool.name.length > 0 + && isObject(tool.inputSchema)); + } catch { + return false; + } +} + +/** MCP Build State 中一个可跨 Platform 复用的 stdio Bundle。 */ +export interface BuiltMcpServer { + readonly id: string; + readonly definition: DiscoveredMcpServers['servers'][number]['definition']; + readonly handler?: GeneratedAssetRef; + readonly licenses?: GeneratedAssetRef; +} + +/** MCP Extension 的不可变 Built State。 */ +export interface BuiltMcpServers { readonly servers: readonly BuiltMcpServer[] } + +/** 通过 Core portable-node 一次编译所有本地 MCP 入口。 */ +export async function buildMcpServers(context: ExtensionBuildContext, validated: Readonly, compile?: PortableNodeCompileOptions): Promise { + /** 仅本地 stdio Server 需要生成 Bundle。 */ + const local = validated.servers.filter(server => server.definition.transport === 'stdio'); + /** 每个本地入口对应一个可执行编译条目。 */ + const entries = Object.fromEntries(local.map(server => [server.id, Object.freeze({ type: 'source' as const, source: server.entrySource!, mode: 0o755 as const })])); + /** 延迟初始化 Compile Result,保证 HTTP-only 不创建 Job。 */ + let result: Awaited> | undefined; + if (local.length > 0) { + try { + /** Core portable-node 统一编译全部 stdio 入口。 */ + result = await context.compiler.compile({ id: 'mcp', profile: 'portable-node', entries, sourceScopes: Object.freeze([validated.root]), ...(compile === undefined ? {} : { options: compile }) }); + } catch (error) { + /** 底层解析失败只映射为稳定 unresolved-import 诊断。 */ + const message = error instanceof Error ? error.message : ''; + if (/dynamic import|unresolved import|could not resolve/iu.test(message)) { + context.diagnostics.report({ code: 'BUILD_UNRESOLVED_IMPORT', severity: 'error', message: 'MCP stdio bundle contains an unresolved import.' }); + } + throw error; + } + } + /** 按 Server ID 把 Core 输出映射为 Extension Built State。 */ + const servers = validated.servers.map((server) => { + if (server.definition.transport === 'http') return Object.freeze({ id: server.id, definition: server.definition }); + /** 当前 stdio entry 的全部 Core 输出。 */ + const outputs = result!.outputs.filter(output => output.outputId === server.id); + /** 固定唯一可执行 main.mjs。 */ + const main = outputs.find(output => output.type === 'chunk' && output.isEntry && output.fileName === 'main.mjs'); + /** 可选相邻第三方许可证材料。 */ + const licenses = outputs.find(output => output.type === 'licenses' && output.fileName === 'THIRD_PARTY_LICENSES.txt'); + if (main === undefined) throw new Error(`Compiler returned no MCP entry for "${server.id}".`); + return Object.freeze({ id: server.id, definition: server.definition, handler: main.asset, ...(licenses === undefined ? {} : { licenses: licenses.asset }) }); + }); + /** 对每个 stdio Bundle 运行固定 JSON-RPC initialize/tools/list smoke。 */ + for (const server of servers) { + if (server.definition.transport !== 'stdio') + continue; + /** 当前 Bundle 的 Core 生成入口引用。 */ + const handler = (server as BuiltMcpServer).handler; + if (handler === undefined) + continue; + /** Smoke 进程只接收作者明确给出的公开 literal 环境。 */ + const environment = Object.fromEntries(Object.entries(server.definition.env ?? {}) + .filter((entry): entry is [string, { readonly value: string }] => 'value' in entry[1]) + .map(([name, source]) => [name, source.value])); + /** 固定的 MCP initialize、initialized 和 tools/list 请求序列。 */ + const input = [ + JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: MCP_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'acplugin-smoke', version: '1' } } }), + JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }), + JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }), + '', + ].join('\n'); + /** Core Execution Host 提供超时、输出上限和最小环境。 */ + const result = await context.execution.runNode({ entry: handler, stdin: input, timeoutMs: 5000, maxOutputBytes: 256 * 1024, environment }); + /** 正常退出和精确 JSON-RPC/MCP response shape 同时成立才通过。 */ + const valid = result.status === 'exited' && result.exitCode === 0 && validateMcpSmokeOutput(result.stdout); + if (!valid) { + /** 原始 stdout/stderr 不进入诊断,避免泄漏作者运行时内容。 */ + context.diagnostics.report({ code: 'MCP_STDIO_SMOKE_FAILED', severity: 'error', message: `MCP Server "${server.id}" failed the initialize/tools-list protocol smoke.` }); + } + } + return Object.freeze({ servers: Object.freeze(servers) }); +} diff --git a/packages/extensions/mcp/src/constants.ts b/packages/extensions/mcp/src/constants.ts new file mode 100644 index 0000000..823aafe --- /dev/null +++ b/packages/extensions/mcp/src/constants.ts @@ -0,0 +1,8 @@ +/** MCP Extension 的稳定包名、配置名和诊断身份。 */ +export const EXTENSION_NAME = '@tokenroll/acplugin-extension-mcp'; + +/** MCP 一级目录接受的小写 kebab-case 格式。 */ +export const MCP_ID_PATTERN: RegExp = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** MCP 环境变量引用接受的可移植名称格式。 */ +export const ENV_NAME_PATTERN: RegExp = /^[A-Za-z_][A-Za-z0-9_]*$/; diff --git a/packages/extensions/mcp/src/contributors/antigravity.ts b/packages/extensions/mcp/src/contributors/antigravity.ts new file mode 100644 index 0000000..8a43502 --- /dev/null +++ b/packages/extensions/mcp/src/contributors/antigravity.ts @@ -0,0 +1,68 @@ +import type { ContributionContext, JsonValue, PlatformContributor } from '@tokenroll/acplugin/sdk'; +import type { BuiltMcpServer, BuiltMcpServers } from '../build.js'; +import { + addJsonAsset, + collector, + finishContribution, + mapValues, + reportAuth, + reportTransport, + serverSubjects, +} from './common.js'; + +/** @returns Antigravity 远程 MCP descriptor。 */ +function descriptor(server: BuiltMcpServer): JsonValue { + if (server.definition.transport !== 'http') + throw new TypeError('Antigravity only accepts remote MCP descriptors.'); + /** values 保存公开 Header 与环境引用。 */ + const values = mapValues(server.definition.headers); + /** headers 使用 Antigravity 的运行时环境插值。 */ + const headers: Record = { + ...values.literal, + ...Object.fromEntries(Object.entries(values.environment).map(([key, name]) => [key, `\${${name}}`])), + }; + if (server.definition.auth?.type === 'bearer') + headers.Authorization = `Bearer \${${server.definition.auth.env}}`; + return { + type: 'http', + url: server.definition.url, + ...(Object.keys(headers).length === 0 ? {} : { headers }), + }; +} + +/** Antigravity Contributor 只交付拥有稳定协议的远程 HTTP MCP。 */ +export const antigravityContributor: PlatformContributor = Object.freeze({ + platform: 'antigravity', + platformApiVersion: '1', + /** 生成 Antigravity remote-only MCP 配置。 */ + async contribute(context: ContributionContext, built: Readonly) { + /** output 汇聚当前 owner 的 Assets 和 compatibility。 */ + const output = collector(); + /** remote 保存最终会进入 Antigravity 配置的 Server。 */ + const remote: BuiltMcpServer[] = []; + for (const server of built.servers) { + if (server.definition.transport === 'stdio') { + reportTransport(output, server, 'unsupported', 'Antigravity has no verified Plugin-local stdio MCP delivery contract.'); + continue; + } + remote.push(server); + reportTransport(output, server, 'native', 'Antigravity supports remote MCP transport.'); + /** 显式 scopes 无法进入 Antigravity 当前静态 descriptor。 */ + const losesScopes = server.definition.auth?.type === 'oauth' && server.definition.auth.scopes !== undefined; + reportAuth( + output, + server, + losesScopes ? 'degraded' : 'native', + losesScopes + ? 'Antigravity cannot preserve configured OAuth scopes in this descriptor.' + : 'Antigravity preserves this MCP authentication policy.', + ); + } + if (remote.length === 0) + return finishContribution(output); + /** descriptors 只包含 remote Server。 */ + const descriptors = Object.fromEntries(remote.map(server => [server.id, descriptor(server)])); + await addJsonAsset(context, output, 'mcp_config.json', { mcpServers: descriptors }, serverSubjects(remote)); + return finishContribution(output); + }, +}); diff --git a/packages/extensions/mcp/src/contributors/claude-code.ts b/packages/extensions/mcp/src/contributors/claude-code.ts new file mode 100644 index 0000000..7685e76 --- /dev/null +++ b/packages/extensions/mcp/src/contributors/claude-code.ts @@ -0,0 +1,73 @@ +import type { ContributionContext, JsonValue, PlatformContributor } from '@tokenroll/acplugin/sdk'; +import type { BuiltMcpServer, BuiltMcpServers } from '../build.js'; +import { + addJsonAsset, + addLocalRuntime, + collector, + finishContribution, + mapValues, + reportAuth, + reportTransport, + serverSubjects, +} from './common.js'; + +/** @returns Claude Code 原生 MCP descriptor。 */ +function descriptor(server: BuiltMcpServer): JsonValue { + /** definition 是经过 Extension 验证的纯数据快照。 */ + const definition = server.definition; + if (definition.transport === 'stdio') { + /** Claude Code env 字段允许 Plugin 运行时环境引用。 */ + const values = mapValues(definition.env); + /** env 合并公开字面量与不会在构建阶段求值的引用。 */ + const environment = { + ...values.literal, + ...Object.fromEntries(Object.entries(values.environment).map(([key, name]) => [key, `\${${name}}`])), + }; + return { + type: 'stdio', + command: 'node', + args: [`\${CLAUDE_PLUGIN_ROOT}/mcp/${server.id}/server.mjs`], + ...(Object.keys(environment).length === 0 ? {} : { env: environment }), + }; + } + /** HTTP Header 使用 Claude Code 的环境变量插值。 */ + const values = mapValues(definition.headers); + /** headers 保留作者字段名和运行时引用。 */ + const headers: Record = { + ...values.literal, + ...Object.fromEntries(Object.entries(values.environment).map(([key, name]) => [key, `\${${name}}`])), + }; + if (definition.auth?.type === 'bearer') + headers.Authorization = `Bearer \${${definition.auth.env}}`; + return { + type: 'http', + url: definition.url, + ...(Object.keys(headers).length === 0 ? {} : { headers }), + ...(definition.auth?.type === 'oauth' + ? { oauth: definition.auth.scopes === undefined ? {} : { scopes: definition.auth.scopes.join(' ') } } + : {}), + }; +} + +/** Claude Code Contributor 交付 Plugin-local stdio 与远程 HTTP MCP。 */ +export const claudeCodeContributor: PlatformContributor = Object.freeze({ + platform: 'claude-code', + platformApiVersion: '1', + /** 生成 Claude Code MCP 配置和本地 Bundle 引用。 */ + async contribute(context: ContributionContext, built: Readonly) { + /** output 汇聚当前 owner 的 Assets 和 compatibility。 */ + const output = collector(); + for (const server of built.servers) { + reportTransport(output, server, 'native', 'Claude Code supports this MCP transport.'); + reportAuth(output, server, 'native', 'Claude Code preserves this MCP authentication policy.'); + if (server.definition.transport === 'stdio') + addLocalRuntime(output, server, 'mcp'); + } + if (built.servers.length === 0) + return finishContribution(output); + /** descriptors 按已稳定排序的 Built State 创建。 */ + const descriptors = Object.fromEntries(built.servers.map(server => [server.id, descriptor(server)])); + await addJsonAsset(context, output, '.mcp.json', { mcpServers: descriptors }, serverSubjects(built.servers)); + return finishContribution(output, [{ document: 'plugin-manifest', path: ['mcpServers'], value: './.mcp.json' }]); + }, +}); diff --git a/packages/extensions/mcp/src/contributors/codex.ts b/packages/extensions/mcp/src/contributors/codex.ts new file mode 100644 index 0000000..0acab50 --- /dev/null +++ b/packages/extensions/mcp/src/contributors/codex.ts @@ -0,0 +1,61 @@ +import type { ContributionContext, JsonValue, PlatformContributor } from '@tokenroll/acplugin/sdk'; +import type { BuiltMcpServer, BuiltMcpServers } from '../build.js'; +import { + addJsonAsset, + addLocalRuntime, + collector, + finishContribution, + mapValues, + reportAuth, + reportTransport, + serverSubjects, +} from './common.js'; + +/** @returns Codex 原生 MCP descriptor。 */ +function descriptor(server: BuiltMcpServer): JsonValue { + /** definition 是平台中立 MCP 描述。 */ + const definition = server.definition; + if (definition.transport === 'stdio') { + /** Codex 分离公开 env 和由宿主透传的变量名。 */ + const values = mapValues(definition.env); + return { + command: 'node', + args: [`./mcp/${server.id}/server.mjs`], + cwd: '.', + ...(Object.keys(values.literal).length === 0 ? {} : { env: values.literal }), + ...(Object.keys(values.environment).length === 0 ? {} : { env_vars: Object.values(values.environment) }), + }; + } + /** Codex 为字面量和环境引用 Header 提供独立字段。 */ + const values = mapValues(definition.headers); + return { + url: definition.url, + ...(definition.auth?.type === 'bearer' ? { bearer_token_env_var: definition.auth.env } : {}), + ...(definition.auth?.type === 'oauth' && definition.auth.scopes !== undefined ? { scopes: definition.auth.scopes } : {}), + ...(Object.keys(values.literal).length === 0 ? {} : { http_headers: values.literal }), + ...(Object.keys(values.environment).length === 0 ? {} : { env_http_headers: values.environment }), + }; +} + +/** Codex Contributor 交付 Plugin-local stdio 与 Streamable HTTP MCP。 */ +export const codexContributor: PlatformContributor = Object.freeze({ + platform: 'codex', + platformApiVersion: '1', + /** 生成 Codex MCP 配置和本地 Bundle 引用。 */ + async contribute(context: ContributionContext, built: Readonly) { + /** output 汇聚当前 owner 的 Assets 和 compatibility。 */ + const output = collector(); + for (const server of built.servers) { + reportTransport(output, server, 'native', 'Codex supports this MCP transport.'); + reportAuth(output, server, 'native', 'Codex preserves this MCP authentication policy.'); + if (server.definition.transport === 'stdio') + addLocalRuntime(output, server, 'mcp'); + } + if (built.servers.length === 0) + return finishContribution(output); + /** descriptors 按已稳定排序的 Built State 创建。 */ + const descriptors = Object.fromEntries(built.servers.map(server => [server.id, descriptor(server)])); + await addJsonAsset(context, output, '.mcp.json', descriptors, serverSubjects(built.servers)); + return finishContribution(output, [{ document: 'plugin-manifest', path: ['mcpServers'], value: './.mcp.json' }]); + }, +}); diff --git a/packages/extensions/mcp/src/contributors/common.ts b/packages/extensions/mcp/src/contributors/common.ts new file mode 100644 index 0000000..a510327 --- /dev/null +++ b/packages/extensions/mcp/src/contributors/common.ts @@ -0,0 +1,128 @@ +import { + stableJson, + type CompatibilityInput, + type ContributionContext, + type JsonValue, + type PackageAssetInput, + type PackageContribution, +} from '@tokenroll/acplugin/sdk'; +import type { BuiltMcpServer } from '../build.js'; +import { compareCodeUnits } from '../sorting.js'; + +/** MCP Contributor 构建结果时使用的 mutable 收集器。 */ +export interface ContributionCollector { + readonly assets: PackageAssetInput[]; + readonly compatibility: CompatibilityInput[]; +} + +/** ValueSource 按目标协议拆分后的公开字面量和环境引用。 */ +export interface MappedValues { + readonly literal: Readonly>; + readonly environment: Readonly>; +} + +/** 将 ValueSource 转换为稳定排序的公开字面量和环境变量名称。 */ +export function mapValues( + input: Readonly> | undefined, +): MappedValues { + /** literal 只包含作者明确允许写入产物的字符串。 */ + const literal: Record = {}; + /** environment 只包含变量名称,绝不读取构建环境。 */ + const environment: Record = {}; + /** 键顺序由确定性 code-unit comparator 固定。 */ + const entries = Object.entries(input ?? {}).sort(([left], [right]) => compareCodeUnits(left, right)); + for (const [key, source] of entries) { + if (source.value !== undefined) + literal[key] = source.value; + else if (source.env !== undefined) + environment[key] = source.env; + } + return Object.freeze({ literal: Object.freeze(literal), environment: Object.freeze(environment) }); +} + +/** 创建一个空的 Contributor 收集器。 */ +export function collector(): ContributionCollector { + return { assets: [], compatibility: [] }; +} + +/** 记录当前平台对一个 MCP transport 的真实支持级别。 */ +export function reportTransport( + output: ContributionCollector, + server: BuiltMcpServer, + level: 'native' | 'unsupported', + reason: string, +): void { + output.compatibility.push(Object.freeze({ + subject: `mcp:${server.id}`, + capability: `transport.${server.definition.transport}`, + level, + reason, + })); +} + +/** 记录可交付 HTTP Server 的认证语义支持级别。 */ +export function reportAuth( + output: ContributionCollector, + server: BuiltMcpServer, + level: 'native' | 'degraded', + reason: string, +): void { + if (server.definition.transport !== 'http' || server.definition.auth === undefined) + return; + output.compatibility.push(Object.freeze({ + subject: `mcp:${server.id}`, + capability: `auth.${server.definition.auth.type}`, + level, + reason, + })); +} + +/** 把同一个 Core Bundle 映射到当前 Platform 固定的本地 MCP 根。 */ +export function addLocalRuntime( + output: ContributionCollector, + server: BuiltMcpServer, + root: string, +): void { + if (server.handler === undefined) + throw new Error(`MCP Server "${server.id}" has no compiled handler.`); + output.assets.push(Object.freeze({ path: `${root}/${server.id}/server.mjs`, asset: server.handler })); + if (server.licenses !== undefined) { + output.assets.push(Object.freeze({ + path: `${root}/${server.id}/THIRD_PARTY_LICENSES.txt`, + asset: server.licenses, + })); + } +} + +/** 通过 Core Asset Service 创建稳定 JSON Package Asset。 */ +export async function addJsonAsset( + context: ContributionContext, + output: ContributionCollector, + path: string, + value: JsonValue, + subjects: readonly string[], +): Promise { + /** JSON bytes 由公开稳定 codec 产生,不写 dist 或自建 workDir。 */ + const asset = await context.assets.fromBytes({ + bytes: stableJson(value), + origin: { operation: 'mcp-platform-config', subjects }, + }); + output.assets.push(Object.freeze({ path, asset })); +} + +/** 完成不可变且 add-only 的 Package Contribution。 */ +export function finishContribution( + output: ContributionCollector, + documentFields: PackageContribution['documentFields'] = [], +): PackageContribution { + return Object.freeze({ + ...(output.assets.length === 0 ? {} : { assets: Object.freeze(output.assets) }), + ...(documentFields.length === 0 ? {} : { documentFields: Object.freeze([...documentFields]) }), + compatibility: Object.freeze(output.compatibility), + }); +} + +/** @returns 当前实际交付 Server 的稳定 compatibility subject 列表。 */ +export function serverSubjects(servers: readonly BuiltMcpServer[]): readonly string[] { + return Object.freeze(servers.map(server => `mcp:${server.id}`)); +} diff --git a/packages/extensions/mcp/src/contributors/cursor.ts b/packages/extensions/mcp/src/contributors/cursor.ts new file mode 100644 index 0000000..2505ccd --- /dev/null +++ b/packages/extensions/mcp/src/contributors/cursor.ts @@ -0,0 +1,64 @@ +import type { ContributionContext, JsonValue, PlatformContributor } from '@tokenroll/acplugin/sdk'; +import type { BuiltMcpServer, BuiltMcpServers } from '../build.js'; +import { + addJsonAsset, + collector, + finishContribution, + mapValues, + reportAuth, + reportTransport, + serverSubjects, +} from './common.js'; + +/** @returns Cursor 远程 MCP descriptor。 */ +function descriptor(server: BuiltMcpServer): JsonValue { + if (server.definition.transport !== 'http') + throw new TypeError('Cursor only accepts remote MCP descriptors.'); + /** values 保存公开 Header 与环境引用。 */ + const values = mapValues(server.definition.headers); + /** headers 使用 Cursor 的 env 插值语法。 */ + const headers: Record = { + ...values.literal, + ...Object.fromEntries(Object.entries(values.environment).map(([key, name]) => [key, `\${env:${name}}`])), + }; + if (server.definition.auth?.type === 'bearer') + headers.Authorization = `Bearer \${env:${server.definition.auth.env}}`; + return { url: server.definition.url, ...(Object.keys(headers).length === 0 ? {} : { headers }) }; +} + +/** Cursor Contributor 只交付拥有稳定协议的远程 HTTP MCP。 */ +export const cursorContributor: PlatformContributor = Object.freeze({ + platform: 'cursor', + platformApiVersion: '1', + /** 生成 Cursor remote-only MCP 配置。 */ + async contribute(context: ContributionContext, built: Readonly) { + /** output 汇聚当前 owner 的 Assets 和 compatibility。 */ + const output = collector(); + /** remote 保存最终会进入 Cursor 配置的 Server。 */ + const remote: BuiltMcpServer[] = []; + for (const server of built.servers) { + if (server.definition.transport === 'stdio') { + reportTransport(output, server, 'unsupported', 'Cursor has no verified Plugin-local stdio MCP delivery contract.'); + continue; + } + remote.push(server); + reportTransport(output, server, 'native', 'Cursor supports remote MCP transport.'); + /** 显式 scopes 无法进入 Cursor 当前静态 descriptor。 */ + const losesScopes = server.definition.auth?.type === 'oauth' && server.definition.auth.scopes !== undefined; + reportAuth( + output, + server, + losesScopes ? 'degraded' : 'native', + losesScopes + ? 'Cursor negotiates OAuth but cannot preserve configured OAuth scopes in this descriptor.' + : 'Cursor preserves this MCP authentication policy.', + ); + } + if (remote.length === 0) + return finishContribution(output); + /** descriptors 只包含 remote Server。 */ + const descriptors = Object.fromEntries(remote.map(server => [server.id, descriptor(server)])); + await addJsonAsset(context, output, 'mcp.json', { mcpServers: descriptors }, serverSubjects(remote)); + return finishContribution(output, [{ document: 'plugin-manifest', path: ['mcpServers'], value: './mcp.json' }]); + }, +}); diff --git a/packages/extensions/mcp/src/contributors/index.ts b/packages/extensions/mcp/src/contributors/index.ts new file mode 100644 index 0000000..27d57a0 --- /dev/null +++ b/packages/extensions/mcp/src/contributors/index.ts @@ -0,0 +1,20 @@ +import type { PlatformContributor } from '@tokenroll/acplugin/sdk'; +import type { BuiltMcpServers } from '../build.js'; +import { antigravityContributor } from './antigravity.js'; +import { claudeCodeContributor } from './claude-code.js'; +import { codexContributor } from './codex.js'; +import { cursorContributor } from './cursor.js'; +import { openCodeContributor } from './opencode.js'; +import { piContributor } from './pi.js'; + +/** @returns 六个互不观察、只消费同一 MCP Built State 的官方 Contributors。 */ +export function createMcpContributors(): readonly PlatformContributor[] { + return Object.freeze([ + claudeCodeContributor, + codexContributor, + cursorContributor, + antigravityContributor, + openCodeContributor, + piContributor, + ]); +} diff --git a/packages/extensions/mcp/src/contributors/opencode.ts b/packages/extensions/mcp/src/contributors/opencode.ts new file mode 100644 index 0000000..d4d0aec --- /dev/null +++ b/packages/extensions/mcp/src/contributors/opencode.ts @@ -0,0 +1,69 @@ +import type { ContributionContext, JsonValue, PlatformContributor } from '@tokenroll/acplugin/sdk'; +import type { BuiltMcpServer, BuiltMcpServers } from '../build.js'; +import { + addLocalRuntime, + collector, + finishContribution, + mapValues, + reportAuth, + reportTransport, +} from './common.js'; + +/** @returns OpenCode 原生 local/remote MCP descriptor。 */ +function descriptor(server: BuiltMcpServer): JsonValue { + /** definition 是平台中立 MCP 描述。 */ + const definition = server.definition; + if (definition.transport === 'stdio') { + /** OpenCode environment 允许字面量和运行时 env 引用。 */ + const values = mapValues(definition.env); + /** environment 保留 canonical key 到宿主变量名的映射。 */ + const environment = { + ...values.literal, + ...Object.fromEntries(Object.entries(values.environment).map(([key, name]) => [key, `{env:${name}}`])), + }; + return { + type: 'local', + command: ['node', `./.opencode/mcp/${server.id}/server.mjs`], + ...(Object.keys(environment).length === 0 ? {} : { environment }), + }; + } + /** OpenCode remote Header 使用运行时 env 引用。 */ + const values = mapValues(definition.headers); + /** headers 保留作者字段名。 */ + const headers: Record = { + ...values.literal, + ...Object.fromEntries(Object.entries(values.environment).map(([key, name]) => [key, `{env:${name}}`])), + }; + if (definition.auth?.type === 'bearer') + headers.Authorization = `Bearer {env:${definition.auth.env}}`; + return { + type: 'remote', + url: definition.url, + ...(Object.keys(headers).length === 0 ? {} : { headers }), + ...(definition.auth?.type === 'oauth' + ? { oauth: definition.auth.scopes === undefined ? {} : { scope: definition.auth.scopes.join(' ') } } + : {}), + }; +} + +/** OpenCode Contributor 只扩展 Platform 拥有的 workspace-config Document。 */ +export const openCodeContributor: PlatformContributor = Object.freeze({ + platform: 'opencode', + platformApiVersion: '1', + /** 追加 OpenCode MCP Document 字段和 Plugin-local Bundle。 */ + contribute(_context: ContributionContext, built: Readonly) { + /** output 汇聚当前 owner 的 Assets 和 compatibility。 */ + const output = collector(); + for (const server of built.servers) { + reportTransport(output, server, 'native', 'OpenCode supports this MCP transport.'); + reportAuth(output, server, 'native', 'OpenCode preserves this MCP authentication policy.'); + if (server.definition.transport === 'stdio') + addLocalRuntime(output, server, '.opencode/mcp'); + } + if (built.servers.length === 0) + return finishContribution(output); + /** workspace-config.mcp 是唯一配置来源,不生成 sidecar。 */ + const descriptors = Object.fromEntries(built.servers.map(server => [server.id, descriptor(server)])); + return finishContribution(output, [{ document: 'workspace-config', path: ['mcp'], value: descriptors }]); + }, +}); diff --git a/packages/extensions/mcp/src/contributors/pi.ts b/packages/extensions/mcp/src/contributors/pi.ts new file mode 100644 index 0000000..40061f7 --- /dev/null +++ b/packages/extensions/mcp/src/contributors/pi.ts @@ -0,0 +1,23 @@ +import type { ContributionContext, PlatformContributor } from '@tokenroll/acplugin/sdk'; +import type { BuiltMcpServers } from '../build.js'; +import { collector, finishContribution, reportTransport } from './common.js'; + +/** Pi Contributor 明确报告 MCP 不可交付且不生成伪配置。 */ +export const piContributor: PlatformContributor = Object.freeze({ + platform: 'pi', + platformApiVersion: '1', + /** 为每个 Server 报告真实 unsupported transport。 */ + contribute(_context: ContributionContext, built: Readonly) { + /** output 只包含 compatibility,不产生 Asset 或 Document 字段。 */ + const output = collector(); + for (const server of built.servers) { + reportTransport( + output, + server, + 'unsupported', + 'Pi has no verified MCP installation contract for this transport.', + ); + } + return finishContribution(output); + }, +}); diff --git a/packages/extensions/mcp/src/discovery.ts b/packages/extensions/mcp/src/discovery.ts new file mode 100644 index 0000000..f9751b2 --- /dev/null +++ b/packages/extensions/mcp/src/discovery.ts @@ -0,0 +1,204 @@ +import { + snapshotJson, + type ExtensionDiscoverContext, + type ExtensionValidateContext, + type SourceDirectoryRef, + type SourceFileRef, +} from '@tokenroll/acplugin/sdk'; +import { MCP_ID_PATTERN, ENV_NAME_PATTERN } from './constants.js'; +import { compareCodeUnits } from './sorting.js'; +import type { McpServer } from './types.js'; + +/** MCP descriptor 顶层字段由 transport 判别后验证。 */ +const FIELDS = new Set(['transport', 'url', 'auth', 'headers', 'entry', 'env']); +/** HTTP 与 stdio 顶层字段集合。 */ +const HTTP_FIELDS = new Set(['transport', 'url', 'auth', 'headers']); +/** stdio 只接受本地入口与环境引用。 */ +const STDIO_FIELDS = new Set(['transport', 'entry', 'env']); +/** 三种认证分支的精确字段集合。 */ +const NONE_AUTH_FIELDS = new Set(['type']); +/** bearer 分支只允许一个环境变量引用。 */ +const BEARER_AUTH_FIELDS = new Set(['type', 'env']); +/** oauth 分支只允许静态 scope 声明。 */ +const OAUTH_AUTH_FIELDS = new Set(['type', 'scopes']); + +/** MCP stdio 入口沿用 Core 的 project-relative POSIX 路径语法。 */ +export function isSafeMcpEntryPath(value: unknown): value is string { + if (typeof value !== 'string' || value.length === 0 || value.includes('\\') || value.includes('\0') || value.startsWith('/')) + return false; + /** POSIX segment 必须全部显式且不能包含 dot traversal。 */ + const segments = value.split('/'); + return segments.every(segment => segment.length > 0 && segment !== '.' && segment !== '..'); +} + +/** MCP Extension 发现的单个 owner-bound Server。 */ +export interface DiscoveredMcpServer { + readonly id: string; + readonly directory: SourceDirectoryRef; + readonly source: SourceFileRef; + /** stdio Server 的实际业务入口;HTTP Server 不包含此字段。 */ + readonly entrySource?: SourceFileRef; + readonly definition: McpServer; +} + +/** MCP Extension 的稳定发现 State。 */ +export interface DiscoveredMcpServers { + readonly root: SourceDirectoryRef; + readonly servers: readonly DiscoveredMcpServer[]; +} + +/** 仅接受普通 JSON 对象,避免 descriptor 把行为带入 State。 */ +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) + && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null); +} + +/** Descriptor 快照只允许 MCP 规范的普通字段。 */ +function normalizeDefinition(value: unknown): McpServer { + /** 先建立无行为 JSON snapshot,再验证 MCP Schema。 */ + const snapshot = snapshotJson(value, 'MCP descriptor'); + if (!isPlainObject(snapshot) || typeof snapshot.transport !== 'string') throw new TypeError('MCP descriptor is invalid.'); + if (Object.keys(snapshot).some(key => !FIELDS.has(key))) throw new TypeError('MCP descriptor contains unknown fields.'); + return snapshot as unknown as McpServer; +} + +/** 发现并加载 src/mcp//mcp.ts。 */ +export async function discoverMcpServers(context: ExtensionDiscoverContext, include?: ReadonlySet): Promise { + /** Extension root 缺失表示本轮没有 MCP 作者资源。 */ + const root = context.roots.mcp; + if (root === undefined) return undefined; + /** Core Source Service 枚举并审计作者目录。 */ + const entries = await context.sources.list(root); + /** 发现成功的 MCP Server 累计列表。 */ + const servers: DiscoveredMcpServer[] = []; + /** include 校验使用的实际目录 ID 集合。 */ + const found = new Set(); + for (const entry of entries) { + if (entry.type !== 'directory' || !MCP_ID_PATTERN.test(entry.name)) { + context.diagnostics.report({ code: 'MCP_ENTRY_INVALID', severity: 'error', message: 'MCP entries must be lowercase kebab-case directories.', location: { path: entry.path } }); + continue; + } + if (include !== undefined && !include.has(entry.name)) continue; + found.add(entry.name); + try { + /** mcp.ts 是每个 Server 的唯一 descriptor 入口。 */ + const source = await context.sources.file(entry.directory, 'mcp.ts'); + /** Module Host 负责安全加载 ESM default export。 */ + const raw = await context.modules.loadDefault({ id: `mcp-${entry.name}`, entry: source }); + /** descriptor 进入纯数据边界。 */ + const definition = normalizeDefinition(raw); + /** stdio 业务入口的受权 SourceRef。 */ + let entrySource: SourceFileRef | undefined; + if (definition.transport === 'stdio' && isSafeMcpEntryPath(definition.entry ?? 'server.ts')) { + try { + entrySource = await context.sources.file(entry.directory, definition.entry ?? 'server.ts'); + } catch { + /** validate 阶段报告稳定缺失入口。 */ + } + } + servers.push(Object.freeze({ id: entry.name, directory: entry.directory, source, definition, ...(entrySource === undefined ? {} : { entrySource }) })); + } catch (error) { + /** 只读取底层错误的稳定类别,不把原始路径带入诊断。 */ + const message = error instanceof Error ? error.message : ''; + context.diagnostics.report({ + code: /unknown fields/iu.test(message) ? 'MCP_FIELD_UNKNOWN' : 'MCP_DESCRIPTOR_LOAD_FAILED', + severity: 'error', + message: /unknown fields/iu.test(message) + ? `MCP Server "${entry.name}" descriptor contains unknown fields.` + : `MCP Server "${entry.name}" descriptor could not be loaded.`, + location: { path: `${entry.path}/mcp.ts` }, + }); + } + } + if (include !== undefined) for (const id of include) if (!found.has(id)) context.diagnostics.report({ code: 'MCP_INCLUDE_MISSING', severity: 'error', message: `Included MCP Server "${id}" does not exist under src/mcp.`, location: { path: `${root.path}/${id}` } }); + return servers.length === 0 ? undefined : Object.freeze({ root, servers: Object.freeze(servers.sort((left, right) => compareCodeUnits(left.id, right.id))) }); +} + +/** 校验 ValueSource 映射且绝不读取 env 引用值。 */ +function validateValues(context: ExtensionValidateContext, server: DiscoveredMcpServer, values: unknown, field: string): void { + if (values === undefined) return; + if (!isPlainObject(values)) { + context.diagnostics.report({ code: 'MCP_VALUE_MAP_INVALID', severity: 'error', message: 'MCP value mappings must be plain objects.', location: { path: server.source.path }, fieldPath: [field] }); + return; + } + for (const [name, source] of Object.entries(values)) { + if (!isPlainObject(source) || Object.keys(source).length !== 1 || (!Object.hasOwn(source, 'value') && !Object.hasOwn(source, 'env')) || (Object.hasOwn(source, 'value') && typeof source.value !== 'string') || (Object.hasOwn(source, 'env') && (typeof source.env !== 'string' || !ENV_NAME_PATTERN.test(source.env)))) + context.diagnostics.report({ code: 'MCP_VALUE_SOURCE_INVALID', severity: 'error', message: `MCP value "${name}" must contain one valid value or env reference.`, location: { path: server.source.path }, fieldPath: [field, name] }); + } +} + +/** 验证判别联合对象没有跨 transport 或跨 auth 分支字段。 */ +function validateExactFields( + context: ExtensionValidateContext, + server: DiscoveredMcpServer, + value: unknown, + allowed: ReadonlySet, + field: string, +): value is Record { + if (!isPlainObject(value)) { + context.diagnostics.report({ code: 'MCP_FIELD_INVALID', severity: 'error', message: `MCP ${field} must be a plain object.`, location: { path: server.source.path }, fieldPath: [field] }); + return false; + } + for (const key of Object.keys(value)) { + if (!allowed.has(key)) + context.diagnostics.report({ code: 'MCP_FIELD_UNKNOWN', severity: 'error', message: `MCP ${field} contains an invalid field for its selected variant.`, location: { path: server.source.path }, fieldPath: [field, key] }); + } + return true; +} + +/** 验证全部 HTTP/stdio MCP 安全约束。 */ +export async function validateMcpServers(context: ExtensionValidateContext, discovered: Readonly): Promise<{ readonly state: Readonly; readonly subjects: readonly { readonly subject: string; readonly capabilities: readonly string[] }[] }> { + for (const server of discovered.servers) { + /** 已快照的联合定义转为只读字段映射。 */ + const definition = server.definition as unknown as Record; + if (definition.transport === 'http') { + if (typeof definition.url !== 'string') context.diagnostics.report({ code: 'MCP_URL_INVALID', severity: 'error', message: 'MCP HTTP url must be a string.', location: { path: server.source.path } }); + else { + try { + /** URL 解析只使用 descriptor 中的公开字符串。 */ + const url = new URL(definition.url); + if (url.username || url.password) throw new Error('credentials'); + if (context.mode === 'production' && url.protocol !== 'https:') throw new Error('https'); + if (context.mode === 'development' + && url.protocol !== 'https:' + && (url.protocol !== 'http:' || !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname))) + throw new Error('scheme'); + } catch (error) { + /** URL 错误归一化为稳定诊断码。 */ + const reason = error instanceof Error ? error.message : ''; + context.diagnostics.report({ code: reason === 'https' ? 'MCP_HTTPS_REQUIRED' : 'MCP_URL_INVALID', severity: 'error', message: 'MCP HTTP url must be HTTPS in production and loopback HTTP in development.', location: { path: server.source.path } }); + } + } + if (definition.auth !== undefined) { + /** 认证对象只检查规范字段,不读取 Secret。 */ + const auth = definition.auth; + if (isPlainObject(auth)) { + if (auth.type === 'none') { + validateExactFields(context, server, auth, NONE_AUTH_FIELDS, 'auth'); + } else if (auth.type === 'bearer') { + validateExactFields(context, server, auth, BEARER_AUTH_FIELDS, 'auth'); + if (typeof auth.env !== 'string' || !ENV_NAME_PATTERN.test(auth.env)) context.diagnostics.report({ code: 'MCP_BEARER_INVALID', severity: 'error', message: 'MCP bearer auth requires a valid env name.', location: { path: server.source.path }, fieldPath: ['auth', 'env'] }); + } else if (auth.type === 'oauth') { + validateExactFields(context, server, auth, OAUTH_AUTH_FIELDS, 'auth'); + if (auth.scopes !== undefined && (!Array.isArray(auth.scopes) || auth.scopes.length === 0 || auth.scopes.some(scope => typeof scope !== 'string' || scope.length === 0) || new Set(auth.scopes).size !== auth.scopes.length)) context.diagnostics.report({ code: 'MCP_OAUTH_INVALID', severity: 'error', message: 'MCP OAuth scopes must be unique non-empty strings.', location: { path: server.source.path }, fieldPath: ['auth', 'scopes'] }); + } else { + validateExactFields(context, server, auth, NONE_AUTH_FIELDS, 'auth'); + context.diagnostics.report({ code: 'MCP_AUTH_INVALID', severity: 'error', message: 'MCP auth type is unsupported.', location: { path: server.source.path }, fieldPath: ['auth', 'type'] }); + } + } else validateExactFields(context, server, auth, NONE_AUTH_FIELDS, 'auth'); + } + validateValues(context, server, definition.headers, 'headers'); + /** HTTP 不接受 stdio 专属字段,即使 descriptor 通过了 TS 类型断言。 */ + validateExactFields(context, server, definition, HTTP_FIELDS, 'server'); + } else if (definition.transport === 'stdio') { + /** stdio 入口默认固定为当前 Server 目录下的 server.ts。 */ + const entry = definition.entry ?? 'server.ts'; + if (!isSafeMcpEntryPath(entry)) context.diagnostics.report({ code: typeof entry === 'string' && (entry.startsWith('/') || entry.split('/').includes('..')) ? 'MCP_ENTRY_ESCAPE' : 'MCP_ENTRY_INVALID', severity: 'error', message: 'MCP stdio entry must be a safe relative POSIX path without dot, parent, backslash, or NUL segments.', location: { path: server.source.path }, fieldPath: ['entry'] }); + if (isSafeMcpEntryPath(entry) && server.entrySource === undefined) context.diagnostics.report({ code: 'MCP_ENTRY_MISSING', severity: 'error', message: 'MCP stdio entry file does not exist.', location: { path: server.source.path }, fieldPath: ['entry'] }); + validateValues(context, server, definition.env, 'env'); + /** stdio 不接受 HTTP 专属字段。 */ + validateExactFields(context, server, definition, STDIO_FIELDS, 'server'); + } else context.diagnostics.report({ code: 'MCP_TRANSPORT_UNSUPPORTED', severity: 'error', message: `MCP Server "${server.id}" transport is unsupported.`, location: { path: server.source.path } }); + } + return Object.freeze({ state: discovered, subjects: Object.freeze(discovered.servers.map(server => Object.freeze({ subject: `mcp:${server.id}`, capabilities: Object.freeze([`transport.${server.definition.transport}`]) }))) }); +} diff --git a/packages/extensions/mcp/src/index.ts b/packages/extensions/mcp/src/index.ts new file mode 100644 index 0000000..f6628be --- /dev/null +++ b/packages/extensions/mcp/src/index.ts @@ -0,0 +1,129 @@ +import { + defineExtension, + type AcpluginExtension, + type JsonObject, + type PortableNodeCompileOptions, +} from '@tokenroll/acplugin/sdk'; +import { buildMcpServers, type BuiltMcpServers } from './build.js'; +import { createMcpContributors } from './contributors/index.js'; +import { MCP_ID_PATTERN } from './constants.js'; +import { + discoverMcpServers, + type DiscoveredMcpServers, + validateMcpServers, +} from './discovery.js'; + +export { EXTENSION_NAME } from './constants.js'; +export type { + BearerMcpAuth, + EnvironmentValueSource, + HttpMcpServer, + LiteralValueSource, + McpAuth, + McpServer, + NoMcpAuth, + OAuthMcpAuth, + StdioMcpServer, + ValueSource, +} from './types.js'; + +/** 创建 MCP Extension 时可声明的横向构建选项。 */ +export interface McpExtensionOptions { + /** 只构建这些 `src/mcp/`;省略时构建全部 Server。 */ + readonly include?: readonly string[]; + /** 复用 Core portable-node 的公共纯 JSON 编译参数。 */ + readonly compile?: PortableNodeCompileOptions; +} + +/** 进入 Core defineExtension 的 JSON-safe MCP options。 */ +type McpJsonOptions = JsonObject; + +/** MCP Extension 工厂当前接受的公开配置字段。 */ +const MCP_OPTION_FIELDS = new Set(['include', 'compile']); + +/** + * 拒绝宽类型变量传入的未知 Extension 工厂字段。 + * + * @param options 配置作者提供的 MCP Extension 选项。 + */ +function validateOptions(options: McpExtensionOptions): void { + if (options === null || typeof options !== 'object' || Array.isArray(options)) + throw new TypeError('MCP options must be a plain object.'); + for (const field of Object.keys(options)) { + if (!MCP_OPTION_FIELDS.has(field)) + throw new TypeError(`Unknown MCP option "${field}".`); + } +} + +/** + * 校验并冻结可选 MCP Server ID 白名单。 + * + * @param include 配置作者提供的可选 ID 数组。 + * @returns 省略时返回 undefined,否则返回去重后的只读集合。 + */ +function normalizeInclude(include: McpExtensionOptions['include']): readonly string[] | undefined { + if (include === undefined) + return undefined; + if (!Array.isArray(include)) + throw new TypeError('MCP include must be an array of lowercase kebab-case IDs.'); + /** 去重后提供给 discover 阶段的 MCP Server ID。 */ + const result = new Set(); + /** id 表示当前显式选择的 MCP Server ID。 */ + for (const id of include) { + if (typeof id !== 'string' || !MCP_ID_PATTERN.test(id)) + throw new TypeError('MCP include must contain only lowercase kebab-case IDs.'); + if (result.has(id)) + throw new TypeError(`MCP include contains duplicate ID "${id}".`); + result.add(id); + } + return Object.freeze([...result].sort()); +} + +/** + * 创建端到端拥有 MCP 作者格式、Bundle 和官方 Contributor 的品牌化 Extension。 + * + * @param options 可选的 MCP Server ID 白名单。 + * @returns 参与 Core 固定生命周期的 MCP Extension。 + */ +export function mcp( + options: McpExtensionOptions = {}, +): AcpluginExtension { + validateOptions(options); + /** factory 边界复制 include,compile 由 Core defineExtension 深度复制。 */ + const include = normalizeInclude(options.include); + /** 规范化后交给 Core 的 Extension options。 */ + const normalized: McpJsonOptions = { + ...(include === undefined ? {} : { include }), + ...(options.compile === undefined ? {} : { compile: options.compile as PortableNodeCompileOptions & JsonObject }), + }; + return defineExtension({ + id: 'mcp', + apiVersion: '1', + /** Core 复制并冻结的作者配置。 */ + options: normalized, + /** MCP Extension 独占的作者资源根。 */ + resourceRoots: ['mcp'], + /** 每个 BuildSession 从 setup integrations 派生不可变平台快照。 */ + createSession({ options: sessionOptions }) { + /** 当前 Session 选中的 Server ID 集合。 */ + const normalized = sessionOptions as McpExtensionOptions; + /** include 白名单只在当前 Session 内使用。 */ + const selected = normalized.include === undefined + ? undefined + : new Set(normalized.include); + /** 当前 Session 共享的 portable-node 编译参数。 */ + const compile = normalized.compile; + return { + /** 扫描 Extension 独占的 `src/mcp` 作者格式。 */ + discover: context => discoverMcpServers(context, selected), + /** 在 Bundle 前验证远程安全策略与本地入口边界。 */ + validate: (context, discovered) => validateMcpServers(context, discovered), + /** HTTP-only 状态不会调用 Build Service;本地 stdio 统一委托给 Core。 */ + build: async (context, validated) => ({ state: await buildMcpServers(context, validated, compile) }), + contributors: createMcpContributors(), + }; + }, + }); +} + +export default mcp; diff --git a/packages/extensions/mcp/src/sorting.ts b/packages/extensions/mcp/src/sorting.ts new file mode 100644 index 0000000..2ab9e53 --- /dev/null +++ b/packages/extensions/mcp/src/sorting.ts @@ -0,0 +1,4 @@ +/** 使用与区域设置无关的 UTF-16 code-unit 顺序。 */ +export function compareCodeUnits(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/packages/extensions/mcp/src/types.ts b/packages/extensions/mcp/src/types.ts new file mode 100644 index 0000000..7ad89bf --- /dev/null +++ b/packages/extensions/mcp/src/types.ts @@ -0,0 +1,64 @@ +/** Header 或进程环境值的公开字面量来源。 */ +export interface LiteralValueSource { + /** 明确允许进入构建产物的非敏感字符串。 */ + readonly value: string; +} + +/** Header 或进程环境值的运行时环境变量来源。 */ +export interface EnvironmentValueSource { + /** 只进入产物的环境变量名称;构建阶段不会读取对应值。 */ + readonly env: string; +} + +/** MCP Header 或环境字段可使用的两种互斥来源。 */ +export type ValueSource = LiteralValueSource | EnvironmentValueSource; + +/** 不需要认证的远程 MCP 声明。 */ +export interface NoMcpAuth { + /** 明确关闭认证。 */ + readonly type: 'none'; +} + +/** 由安装平台完成授权流程的 OAuth 声明。 */ +export interface OAuthMcpAuth { + /** 使用平台原生 OAuth 支持。 */ + readonly type: 'oauth'; + /** 请求的非空 OAuth Scope。 */ + readonly scopes?: readonly string[]; +} + +/** 从宿主环境读取 Token 的 Bearer 认证声明。 */ +export interface BearerMcpAuth { + /** 使用 Bearer Token。 */ + readonly type: 'bearer'; + /** 运行时读取 Token 的环境变量名称。 */ + readonly env: string; +} + +/** 规范远程 MCP 支持的认证策略。 */ +export type McpAuth = NoMcpAuth | OAuthMcpAuth | BearerMcpAuth; + +/** 远程 HTTP MCP Server 的平台中立静态描述。 */ +export interface HttpMcpServer { + /** 固定为远程 HTTP 传输。 */ + readonly transport: 'http'; + /** Server 的完整 HTTPS 或开发期 loopback URL。 */ + readonly url: string; + /** 无认证、OAuth 或 Bearer 环境变量认证。 */ + readonly auth?: McpAuth; + /** 公开字面量或运行时环境变量 Header。 */ + readonly headers?: Readonly>; +} + +/** 由作者提供完整实现的本地 stdio MCP Server 描述。 */ +export interface StdioMcpServer { + /** 固定为本地 stdio 传输。 */ + readonly transport: 'stdio'; + /** 相对于当前 MCP 目录的安全 POSIX 入口,默认 `server.ts`。 */ + readonly entry?: string; + /** 传给 Server 进程的公开字面量或运行时环境变量。 */ + readonly env?: Readonly>; +} + +/** 远程 HTTP 与本地 stdio 组成的规范 MCP Server 联合类型。 */ +export type McpServer = HttpMcpServer | StdioMcpServer; diff --git a/packages/extensions/mcp/test/authoring-discovery.test.ts b/packages/extensions/mcp/test/authoring-discovery.test.ts new file mode 100644 index 0000000..63af70b --- /dev/null +++ b/packages/extensions/mcp/test/authoring-discovery.test.ts @@ -0,0 +1,121 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import mcp, { EXTENSION_NAME } from '../src/index.js'; +import { compareCodeUnits } from '../src/sorting.js'; +import { createProject, runProject } from './fixture.js'; + +describe('MCP Extension authoring and discovery', () => { + it('exposes plain descriptor types, filters resources, and rejects invalid options', async () => { + /** 公开工厂创建的默认 MCP Extension。 */ + const extension = mcp(); + expect(EXTENSION_NAME).toBe('@tokenroll/acplugin-extension-mcp'); + expect(extension.id).toBe('mcp'); + expect(extension.resourceRoots).toEqual(['mcp']); + expect(Object.isFrozen(extension)).toBe(true); + expect(() => mcp({ include: ['docs', 'docs'] })).toThrow('duplicate ID'); + expect(() => mcp({ include: ['Not-Kebab'] })).toThrow('lowercase kebab-case'); + expect(() => mcp({ include: ['mcp-é'] })).toThrow('lowercase kebab-case'); + expect(() => mcp({ unknown: true } as never)).toThrow('Unknown MCP option'); + + /** include 只选择远程 Server 的真实工程。 */ + const root = await createProject({ mcpOptions: `{ include: ['docs'] }` }); + /** 筛选后的双 Platform 构建结果。 */ + const result = await runProject({ cwd: root, command: 'build', mode: 'production' }); + expect(result.success).toBe(true); + await expect(fs.access(path.join(root, 'dist/codex/plugin/mcp/local-tools/server.mjs'))).rejects.toThrow(); + expect(JSON.parse(await fs.readFile(path.join(root, 'dist/codex/plugin/.mcp.json'), 'utf8'))) + .toHaveProperty('docs.url', 'https://mcp.example.com/mcp'); + }); + + it('uses locale-independent code-unit ordering for deterministic internal maps', () => { + /** 非 ASCII 样本证明排序不委托给宿主 locale 或 ICU。 */ + const values = ['é', 'z', 'ä', 'a']; + expect(values.sort(compareCodeUnits)).toEqual(['a', 'z', 'ä', 'é']); + }); + + it('rejects non-enumerable descriptor accessors without evaluating them', async () => { + /** 不可枚举 getter 不能绕过 plain descriptor 的无行为数据边界。 */ + const root = await createProject({ + local: false, + remote: `(() => { + const value = { transport: 'http', url: 'https://mcp.example.com/mcp' }; + Object.defineProperty(value, 'hidden', { get() { throw new Error('MUST_NOT_RUN'); } }); + return value; + })() as never`, + }); + /** discover 以稳定错误码拒绝,并且原始 getter 文本不进入诊断。 */ + const result = await runProject({ cwd: root, command: 'validate', mode: 'production' }); + + expect(result.success).toBe(false); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_DESCRIPTOR_LOAD_FAILED' })); + expect(JSON.stringify(result.diagnostics)).not.toContain('MUST_NOT_RUN'); + }); + + it('rejects non-enumerable unknown descriptor fields', async () => { + /** strict JSON snapshot 直接拒绝不可枚举 data property。 */ + const root = await createProject({ + local: false, + remote: `(() => { + const value = { transport: 'http', url: 'https://mcp.example.com/mcp' }; + Object.defineProperty(value, 'hidden', { value: true }); + return value; + })() as never`, + }); + /** 隐藏字段不能被静默丢弃,也不能进入跨阶段 State。 */ + const result = await runProject({ cwd: root, command: 'validate', mode: 'production' }); + + expect(result.success).toBe(false); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_DESCRIPTOR_LOAD_FAILED' })); + }); + + it('rejects array accessors, custom fields, Symbols and __proto__ fields without executing accessors', async () => { + /** nested array getter 写 stdout;若被执行会直接破坏子进程 JSON 协议并使测试失败。 */ + const accessorRoot = await createProject({ + local: false, + remote: `(() => { + const scopes = []; + Object.defineProperty(scopes, '0', { get() { process.stdout.write('GETTER_EXECUTED'); return 'docs:read'; } }); + Object.defineProperty(scopes, 'length', { value: 1 }); + const value = { transport: 'http', url: 'https://mcp.example.com/mcp', auth: { type: 'oauth', scopes } }; + Object.defineProperty(value, '__proto__', { value: true }); + return value; + })() as never`, + }); + /** 快照必须在执行 getter 前拒绝整个 descriptor。 */ + const accessor = await runProject({ cwd: accessorRoot, command: 'validate', mode: 'production' }); + + expect(accessor.success).toBe(false); + expect(accessor.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_DESCRIPTOR_LOAD_FAILED' })); + + /** 类似索引的自定义字段也不能被 snapshot 静默忽略。 */ + const fieldRoot = await createProject({ + local: false, + remote: `(() => { + const scopes = ['docs:read']; + Object.defineProperty(scopes, '01', { value: 'docs:write' }); + return { transport: 'http', url: 'https://mcp.example.com/mcp', auth: { type: 'oauth', scopes } }; + })() as never`, + }); + /** 伪索引必须在 discover 数据边界失败。 */ + const field = await runProject({ cwd: fieldRoot, command: 'validate', mode: 'production' }); + + expect(field.success).toBe(false); + expect(field.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_DESCRIPTOR_LOAD_FAILED' })); + + /** 字符串字段检查不能遗漏数组自身携带的 Symbol。 */ + const symbolRoot = await createProject({ + local: false, + remote: `(() => { + const scopes = ['docs:read']; + Object.defineProperty(scopes, Symbol.for('hidden'), { value: true }); + return { transport: 'http', url: 'https://mcp.example.com/mcp', auth: { type: 'oauth', scopes } }; + })() as never`, + }); + /** Symbol 不能进入纯 JSON descriptor State。 */ + const symbol = await runProject({ cwd: symbolRoot, command: 'validate', mode: 'production' }); + + expect(symbol.success).toBe(false); + expect(symbol.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_DESCRIPTOR_LOAD_FAILED' })); + }); +}); diff --git a/packages/extensions/mcp/test/build-contributors.test.ts b/packages/extensions/mcp/test/build-contributors.test.ts new file mode 100644 index 0000000..670d606 --- /dev/null +++ b/packages/extensions/mcp/test/build-contributors.test.ts @@ -0,0 +1,77 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { createProject, executeNode, runProject } from './fixture.js'; + +describe('MCP Extension build and contributors', () => { + it('builds remote and local Servers once without reading or leaking Secret values', async () => { + /** 同时覆盖 HTTP、stdio、环境引用和第三方许可的工程。 */ + const root = await createProject(); + /** 用可检测的 Secret 值证明构建阶段只保留变量名称。 */ + const secret = 'MUST_NOT_APPEAR_IN_BUILD_OUTPUT_9f6a'; + /** 完整提交双 Platform 交付单元的构建结果。 */ + const result = await runProject( + { cwd: root, command: 'build', mode: 'production' }, + { DOCS_TOKEN: secret, DOCS_TENANT: secret, LOCAL_TOKEN: secret }, + ); + expect(result.success).toBe(true); + + /** Claude Code wrapped MCP 清单。 */ + const claude = JSON.parse(await fs.readFile( + path.join(root, 'dist/claude-code/plugin/.mcp.json'), + 'utf8', + )) as Record; + /** Codex direct MCP 清单。 */ + const codex = JSON.parse(await fs.readFile( + path.join(root, 'dist/codex/plugin/.mcp.json'), + 'utf8', + )) as Record; + expect(claude).toHaveProperty('mcpServers.docs.headers.Authorization', 'Bearer ${DOCS_TOKEN}'); + expect(claude).toHaveProperty('mcpServers.local-tools.args.0', '${CLAUDE_PLUGIN_ROOT}/mcp/local-tools/server.mjs'); + expect(codex).toMatchObject({ + 'docs': { + url: 'https://mcp.example.com/mcp', + bearer_token_env_var: 'DOCS_TOKEN', + env_http_headers: { 'X-Tenant': 'DOCS_TENANT' }, + http_headers: { 'X-Client': 'acplugin-test' }, + }, + 'local-tools': { + command: 'node', + args: ['./mcp/local-tools/server.mjs'], + cwd: '.', + env: { LOG_LEVEL: 'warn' }, + env_vars: ['LOCAL_TOKEN'], + }, + }); + expect(JSON.stringify({ result, claude, codex })).not.toContain(secret); + + /** 两个平台复用同一平台中立 Server Bundle。 */ + const claudeServer = path.join(root, 'dist/claude-code/plugin/mcp/local-tools/server.mjs'); + /** Codex 安装包中的同一 Server Bundle。 */ + const codexServer = path.join(root, 'dist/codex/plugin/mcp/local-tools/server.mjs'); + expect(await fs.readFile(claudeServer)).toEqual(await fs.readFile(codexServer)); + expect((await fs.stat(codexServer)).mode & 0o111).not.toBe(0); + expect(await fs.readFile( + path.join(root, 'dist/codex/plugin/mcp/local-tools/THIRD_PARTY_LICENSES.txt'), + 'utf8', + )).toContain('mcp-fixture-dependency@4.5.6'); + + /** 使用真实 initialize/list-tools JSON-RPC 流验证安装产物可执行。 */ + const protocolInput = [ + JSON.stringify({ + jsonrpc: '2.0', id: 1, method: 'initialize', + params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'test', version: '1.0.0' } }, + }), + JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }), + JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }), + '', + ].join('\n'); + /** 本地 Bundle 的实际协议响应。 */ + const execution = await executeNode([codexServer], protocolInput); + expect(execution).toMatchObject({ code: 0, stderr: '' }); + expect(execution.stdout.trim().split('\n').map(line => JSON.parse(line))).toEqual([ + expect.objectContaining({ id: 1, result: expect.objectContaining({ serverInfo: { name: 'fixture', version: '1.0.0' } }) }), + { jsonrpc: '2.0', id: 2, result: { tools: [] } }, + ]); + }); +}); diff --git a/packages/extensions/mcp/test/discovery-validation.test.ts b/packages/extensions/mcp/test/discovery-validation.test.ts new file mode 100644 index 0000000..df3ac74 --- /dev/null +++ b/packages/extensions/mcp/test/discovery-validation.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import { createProject, runProject } from './fixture.js'; + +describe('MCP Extension discovery validation', () => { + it('enforces production URL, value-source, entry, and include safety', async () => { + /** 使用 HTTP、非法认证和值来源的远程定义。 */ + const remoteRoot = await createProject({ + local: false, + remote: `{ + transport: 'http', + url: 'http://example.com/mcp', + auth: { type: 'bearer', env: 'INVALID-NAME' }, + headers: { 'X-Secret': { value: 'public', env: 'PRIVATE_TOKEN' } }, + } as never`, + mcpOptions: `{ include: ['docs'] }`, + }); + /** 远程安全策略产生的结构化失败结果。 */ + const remote = await runProject({ cwd: remoteRoot, command: 'validate', mode: 'production' }); + expect(remote.success).toBe(false); + expect(remote.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'MCP_HTTPS_REQUIRED' }), + expect.objectContaining({ code: 'MCP_BEARER_INVALID' }), + expect.objectContaining({ code: 'MCP_VALUE_SOURCE_INVALID' }), + ])); + /** 单独工程验证 include 指向不存在资源时的诊断。 */ + const includeRoot = await createProject({ local: false, mcpOptions: `{ include: ['missing'] }` }); + /** 执行 include fixture 并读取稳定诊断。 */ + const include = await runProject({ cwd: includeRoot, command: 'validate', mode: 'production' }); + expect(include.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_INCLUDE_MISSING' })); + + /** 使用目录逃逸入口的本地定义。 */ + const localRoot = await createProject({ + remote: false, + local: `{ transport: 'stdio', entry: '../outside.ts' }`, + }); + /** 入口边界验证必须在 Bundle 之前失败。 */ + const local = await runProject({ cwd: localRoot, command: 'validate', mode: 'production' }); + expect(local.success).toBe(false); + expect(local.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_ENTRY_ESCAPE' })); + }); + + it('enforces exact transport, auth, URL, and stdio entry variants', async () => { + /** HTTP 不能携带 stdio 字段,none auth 不能携带 bearer 字段。 */ + const httpRoot = await createProject({ + local: false, + remote: `{ transport: 'http', url: 'https://mcp.example.com/mcp', entry: 'server.ts', env: {}, auth: { type: 'none', env: 'TOKEN' } } as never`, + }); + /** 跨判别分支字段必须在 Extension validate 阶段失败。 */ + const http = await runProject({ cwd: httpRoot, command: 'validate', mode: 'production' }); + expect(http.success).toBe(false); + expect(http.diagnostics.filter(diagnostic => diagnostic.code === 'MCP_FIELD_UNKNOWN').length).toBeGreaterThanOrEqual(3); + + /** bearer 与 oauth 认证分支各自拒绝另一分支的字段。 */ + for (const auth of [ + `{ type: 'bearer', env: 'TOKEN', scopes: ['docs:read'] }`, + `{ type: 'oauth', scopes: ['docs:read'], env: 'TOKEN' }`, + ]) { + /** 当前认证分支交叉字段的独立 HTTP fixture。 */ + const authRoot = await createProject({ + local: false, + remote: `{ transport: 'http', url: 'https://mcp.example.com/mcp', auth: ${auth} } as never`, + }); + /** exact discriminated union 必须在领域 validate 阶段拒绝交叉字段。 */ + const authResult = await runProject({ cwd: authRoot, command: 'validate', mode: 'production' }); + expect(authResult.success).toBe(false); + expect(authResult.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_FIELD_UNKNOWN' })); + } + + /** stdio 不能携带 HTTP 字段或任何 HTTP auth。 */ + const stdioRoot = await createProject({ + remote: false, + local: `{ transport: 'stdio', entry: 'server.ts', url: 'https://mcp.example.com', headers: {}, auth: { type: 'bearer', env: 'TOKEN' } } as never`, + }); + /** 顶层 transport exact union 不依赖 TypeScript 静态检查。 */ + const stdio = await runProject({ cwd: stdioRoot, command: 'validate', mode: 'production' }); + expect(stdio.success).toBe(false); + expect(stdio.diagnostics.filter(diagnostic => diagnostic.code === 'MCP_FIELD_UNKNOWN').length).toBeGreaterThanOrEqual(3); + + /** development 也只允许 HTTPS 或 loopback HTTP,不能放行其他 scheme。 */ + const schemeRoot = await createProject({ local: false, remote: `{ transport: 'http', url: 'ftp://localhost/mcp' }` }); + /** 非 HTTP(S) scheme 必须产生稳定 URL 失败。 */ + const scheme = await runProject({ cwd: schemeRoot, command: 'validate', mode: 'development' }); + expect(scheme.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_URL_INVALID' })); + + /** 文档化的 canonical entry 和 development loopback URL 都合法。 */ + const validRoot = await createProject({ remote: `{ transport: 'http', url: 'http://127.0.0.1:3000/mcp' }`, local: `{ transport: 'stdio', entry: 'server.ts' }` }); + /** validate 不执行 stdio smoke,但应完整通过作者 schema。 */ + const valid = await runProject({ cwd: validRoot, command: 'validate', mode: 'development' }); + expect(valid.success).toBe(true); + + /** dot、空 segment、反斜线和父目录 spelling 都不能被静默 normalize。 */ + for (const entry of ['./server.ts', '.', 'nested//server.ts', 'nested\\server.ts', '../server.ts', '/server.ts']) { + /** 每个非法 spelling 使用独立工程,避免诊断相互掩盖。 */ + const root = await createProject({ remote: false, local: `{ transport: 'stdio', entry: ${JSON.stringify(entry)} }` }); + /** 路径语法错误必须与真实缺失文件区分。 */ + const result = await runProject({ cwd: root, command: 'validate', mode: 'production' }); + expect(result.success).toBe(false); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ + code: entry.startsWith('/') || entry.includes('../') ? 'MCP_ENTRY_ESCAPE' : 'MCP_ENTRY_INVALID', + })); + expect(result.diagnostics).not.toContainEqual(expect.objectContaining({ code: 'MCP_ENTRY_MISSING' })); + } + }); +}); diff --git a/packages/extensions/mcp/test/fixture.ts b/packages/extensions/mcp/test/fixture.ts new file mode 100644 index 0000000..06ae3ec --- /dev/null +++ b/packages/extensions/mcp/test/fixture.ts @@ -0,0 +1,274 @@ +import { spawn } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach } from 'vitest'; +import type { BuildReport, RunProjectOptions } from '@tokenroll/acplugin'; + +/** 当前测试文件所在仓库的绝对根目录。 */ +const repositoryRoot = path.resolve(import.meta.dirname, '../../../..'); + +/** 测试描述文件通过临时包入口加载的 MCP Extension 构建产物。 */ +const extensionEntry = path.join(repositoryRoot, 'packages/extensions/mcp/dist/index.mjs'); + +/** 测试子进程直接加载的主包构建产物。 */ +const acpluginEntry = path.join(repositoryRoot, 'packages/acplugin/dist/index.mjs'); + +/** MCP 生命周期测试显式配置的两个独立 Platform 构建入口。 */ +const claudeCodeEntry = path.join(repositoryRoot, 'packages/platforms/claude-code/dist/index.mjs'); +/** MCP 生命周期测试显式配置的 Codex Platform 构建入口。 */ +const codexEntry = path.join(repositoryRoot, 'packages/platforms/codex/dist/index.mjs'); + +/** 真实 MCP SDK package root,测试工程通过正常 package-manager symlink 使用。 */ +const mcpSdkRoot = path.resolve(path.dirname(fileURLToPath(import.meta.resolve('@modelcontextprotocol/sdk/server/index.js'))), '../../..'); + +/** 当前测试创建并在 afterEach 中统一删除的临时工程。 */ +const temporaryRoots: string[] = []; + +/** 子进程的稳定退出状态和有限输出。 */ +interface ProcessResult { + /** Node 子进程退出码。 */ + readonly code: number | null; + /** 子进程完整标准输出。 */ + readonly stdout: string; + /** 子进程完整标准错误。 */ + readonly stderr: string; +} + +/** 创建临时规范工程时使用的 MCP fixture 选项。 */ +interface ProjectFixtureOptions { + /** 直接传入 `mcp(...)` 的可选 TypeScript 参数表达式。 */ + readonly mcpOptions?: string; + /** 远程 Server 描述对象表达式;false 表示不创建。 */ + readonly remote?: string | false; + /** 本地 Server 描述对象表达式;false 表示不创建。 */ + readonly local?: string | false; + /** 本地 Server 入口源码。 */ + readonly serverSource?: string; + /** 构建命令使用的顶层配置补充。 */ + readonly configFields?: string; +} + +/** + * 在原生 Node ESM 子进程中运行公开 API,确保共享 registry brand 只绑定一个主包实例。 + * + * @param options 可 JSON 序列化的项目运行选项。 + * @param environment 测试构建阶段显式加入的环境变量。 + * @returns 公开 API 产生的结构化 BuildReport。 + */ +export async function runProject( + options: RunProjectOptions, + environment: Readonly> = {}, +): Promise { + /** 子进程直接导入真实主包构建产物并序列化结果的 ESM 源码。 */ + const source = ` +import { runProject } from ${JSON.stringify(acpluginEntry)}; +try { + const result = await runProject(${JSON.stringify(options)}); + process.stdout.write(JSON.stringify({ ok: true, result })); +} catch (error) { + process.stdout.write(JSON.stringify({ + ok: false, + name: error instanceof Error ? error.name : 'Error', + message: error instanceof Error ? error.message : 'Project execution failed.', + diagnostics: error && typeof error === 'object' && 'diagnostics' in error ? error.diagnostics : [], + })); +} +`; + /** 原生 ESM 子进程的执行结果。 */ + const execution = await executeNode(['--input-type=module', '--eval', source], '', environment); + if (execution.code !== 0) + throw new Error(`Project subprocess failed: ${execution.stderr}`); + /** 子进程返回的成功结果或安全异常摘要。 */ + const payload = JSON.parse(execution.stdout) as { + readonly ok: boolean; + readonly result?: BuildReport; + readonly name?: string; + readonly message?: string; + }; + if (!payload.ok || payload.result === undefined) + throw new Error(`${payload.name ?? 'Error'}: ${payload.message ?? 'Project execution failed.'}`); + return payload.result; +} + +/** + * 运行一个 Node 子进程并完整收集测试所需输出。 + * + * @param arguments_ 传给 Node 的参数。 + * @param input 写入标准输入的协议文本。 + * @param environment 追加到宿主环境的测试变量。 + * @returns 稳定退出码和标准输出、错误输出。 + */ +export async function executeNode( + arguments_: readonly string[], + input: string, + environment: Readonly> = {}, +): Promise { + return new Promise((resolve, reject) => { + /** 不经过 shell 的真实 Node 子进程。 */ + const child = spawn(process.execPath, arguments_, { + env: { ...process.env, ...environment }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + /** 子进程累计的标准输出。 */ + let stdout = ''; + /** 子进程累计的标准错误。 */ + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + child.on('error', reject); + child.on('close', code => resolve({ code, stdout, stderr })); + child.stdin.end(input); + }); +} + +/** + * 在临时工程中创建可由统一 Module Service 和 Rolldown 共同解析的 Extension 包入口。 + * + * @param root 临时工程根目录。 + */ +async function writeExtensionProxy(root: string): Promise { + /** 临时 node_modules 中的 MCP Extension 包目录。 */ + const packageRoot = path.join(root, 'node_modules/@tokenroll/acplugin-extension-mcp'); + await fs.mkdir(packageRoot, { recursive: true }); + await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ + name: '@tokenroll/acplugin-extension-mcp', + version: '1.0.0', + type: 'module', + exports: './index.mjs', + })); + await fs.writeFile( + path.join(packageRoot, 'index.mjs'), + `export * from ${JSON.stringify(extensionEntry)}; export { default } from ${JSON.stringify(extensionEntry)};\n`, + ); + /** 主包与 SDK 代理保持与真实 tarball 相同的 package identity。 */ + const acpluginRoot = path.join(root, 'node_modules/@tokenroll/acplugin'); + await fs.mkdir(acpluginRoot, { recursive: true }); + await fs.writeFile(path.join(acpluginRoot, 'package.json'), JSON.stringify({ + name: '@tokenroll/acplugin', version: '1.0.0', type: 'module', exports: { '.': './index.mjs', './sdk': './sdk.mjs' }, + })); + await fs.writeFile(path.join(acpluginRoot, 'index.mjs'), `export * from ${JSON.stringify(acpluginEntry)};\n`); + await fs.writeFile(path.join(acpluginRoot, 'sdk.mjs'), `export * from ${JSON.stringify(path.join(repositoryRoot, 'packages/acplugin/dist/sdk.mjs'))};\n`); + for (const [name, entry] of [ + ['@tokenroll/acplugin-platform-claude-code', claudeCodeEntry], + ['@tokenroll/acplugin-platform-codex', codexEntry], + ] as const) { + /** 当前官方 Platform 的测试代理目录。 */ + const platformRoot = path.join(root, 'node_modules', name); + await fs.mkdir(platformRoot, { recursive: true }); + await fs.writeFile(path.join(platformRoot, 'package.json'), JSON.stringify({ name, version: '1.0.0', type: 'module', exports: './index.mjs' })); + await fs.writeFile(path.join(platformRoot, 'index.mjs'), `export * from ${JSON.stringify(entry)}; export { default } from ${JSON.stringify(entry)};\n`); + } + /** pnpm 依赖 symlink 是合法 package 边界,不属于作者源码 symlink。 */ + const sdkRoot = path.join(root, 'node_modules/@modelcontextprotocol/sdk'); + await fs.mkdir(path.dirname(sdkRoot), { recursive: true }); + await fs.symlink(mcpSdkRoot, sdkRoot, 'dir'); +} + +/** + * 写入一个实际参与 Bundle 和第三方许可收集的本地 npm 依赖。 + * + * @param root 临时工程根目录。 + */ +async function writeLicensedDependency(root: string): Promise { + /** 临时 node_modules 中的第三方测试包目录。 */ + const packageRoot = path.join(root, 'node_modules/mcp-fixture-dependency'); + await fs.mkdir(packageRoot, { recursive: true }); + await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ + name: 'mcp-fixture-dependency', + version: '4.5.6', + type: 'module', + exports: './index.js', + license: 'MIT', + })); + await fs.writeFile(path.join(packageRoot, 'index.js'), 'export const serverName = "fixture";\n'); + await fs.writeFile(path.join(packageRoot, 'LICENSE'), 'MCP fixture dependency license.\n'); +} + +/** + * 创建带最小 Skill、配置和可选远程/本地 MCP 的真实临时工程。 + * + * @param options MCP 定义、入口和构建配置。 + * @returns 已登记清理的工程绝对路径。 + */ +export async function createProject(options: ProjectFixtureOptions = {}): Promise { + /** 当前测试独占的临时工程根目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-mcp-test-')); + temporaryRoots.push(root); + await writeExtensionProxy(root); + await writeLicensedDependency(root); + await fs.mkdir(path.join(root, 'src/skills/hello'), { recursive: true }); + await fs.writeFile( + path.join(root, 'src/skills/hello/SKILL.md'), + '---\ndescription: Say hello.\n---\nSay hello to the user.\n', + ); + if (options.remote !== false) { + await fs.mkdir(path.join(root, 'src/mcp/docs'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/mcp/docs/mcp.ts'), ` +import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; +export default ${options.remote ?? `{ + transport: 'http', + url: 'https://mcp.example.com/mcp', + auth: { type: 'bearer', env: 'DOCS_TOKEN' }, + headers: { 'X-Tenant': { env: 'DOCS_TENANT' }, 'X-Client': { value: 'acplugin-test' } }, +}`} satisfies McpServer; +`); + } + if (options.local !== false) { + await fs.mkdir(path.join(root, 'src/mcp/local-tools'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/mcp/local-tools/mcp.ts'), ` +import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; +export default ${options.local ?? `{ + transport: 'stdio', + env: { LOG_LEVEL: { value: 'warn' }, API_TOKEN: { env: 'LOCAL_TOKEN' } }, +}`} satisfies McpServer; +`); + await fs.writeFile(path.join(root, 'src/mcp/local-tools/server.ts'), options.serverSource ?? ` +import { serverName } from 'mcp-fixture-dependency'; +let buffer = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + buffer += chunk; + const lines = buffer.split('\\n'); + buffer = lines.pop() ?? ''; + for (const line of lines.filter(Boolean)) { + const message = JSON.parse(line); + if (message.method === 'initialize') { + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, result: { + protocolVersion: message.params.protocolVersion, + capabilities: { tools: {} }, + serverInfo: { name: serverName, version: '1.0.0' }, + } }) + '\\n'); + } else if (message.method === 'tools/list') { + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, result: { tools: [] } }) + '\\n'); + } + } +}); +`); + } + await fs.writeFile(path.join(root, 'acplugin.config.ts'), ` +import mcp from '@tokenroll/acplugin-extension-mcp'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; +export default { + name: 'mcp-fixture', + version: '1.0.0', + description: 'MCP integration fixture.', + platforms: [claudeCode(), codex()], + extensions: [mcp(${options.mcpOptions ?? ''})], + ${options.configFields ?? 'build: { strict: false },'} +}; +`); + return root; +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); diff --git a/packages/extensions/mcp/test/protocol.test.ts b/packages/extensions/mcp/test/protocol.test.ts new file mode 100644 index 0000000..12a958c --- /dev/null +++ b/packages/extensions/mcp/test/protocol.test.ts @@ -0,0 +1,88 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { createProject, runProject } from './fixture.js'; + +describe('MCP Extension protocol', () => { + it('accepts a complete server implemented with the official MCP SDK', async () => { + /** SDK Server 提供真实 initialize 协商和 tools/list handler。 */ + const root = await createProject({ + remote: false, + serverSource: ` +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +const server = new Server({ name: 'sdk-fixture', version: '1.0.0' }, { capabilities: { tools: {} } }); +server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [] })); +await server.connect(new StdioServerTransport()); +`, + }); + /** 真实 SDK 响应必须通过同一 Core Execution Host smoke。 */ + const result = await runProject({ cwd: root, command: 'build', mode: 'production' }); + expect(result.success, JSON.stringify(result)).toBe(true); + expect(result.diagnostics).not.toContainEqual(expect.objectContaining({ code: 'MCP_STDIO_SMOKE_FAILED' })); + }); + + it('rejects protocol-shaped output that is not a valid MCP handshake', async () => { + /** 所有 case 都会正常退出并打印 JSON,差异只在 JSON-RPC/MCP shape。 */ + const validInitialize = { jsonrpc: '2.0', id: 1, result: { protocolVersion: '2025-11-25', capabilities: {}, serverInfo: { name: 'fixture', version: '1.0.0' } } }; + /** 标准空 tool list 响应。 */ + const validTools = { jsonrpc: '2.0', id: 2, result: { tools: [] } }; + /** 旧实现会误接受的响应及各类 envelope/result 反例。 */ + const cases: readonly (readonly unknown[])[] = [ + [{ id: 1, result: {} }, { id: 2, result: {} }], + [validInitialize, validInitialize, validTools], + [{ jsonrpc: '2.0', id: 1, error: { code: -32_000, message: 'failed' } }, validTools], + [{ ...validInitialize, jsonrpc: '1.0' }, validTools], + [{ jsonrpc: '2.0', id: 1, result: 'initialized' }, validTools], + [validInitialize, { jsonrpc: '2.0', id: 2, result: { tools: [{ name: 'broken' }] } }], + ]; + for (const messages of cases) { + /** Fixture 不解析输入,只伪造旧 validator 所需的两行 JSON。 */ + const stdout = `${messages.map(message => JSON.stringify(message)).join('\n')}\n`; + /** 每个反例独立编译和执行,证明失败发生在真实 Extension build path。 */ + const root = await createProject({ remote: false, serverSource: `process.stdout.write(${JSON.stringify(stdout)});\n` }); + /** 伪 handshake 不得形成可提交 Platform candidate。 */ + const result = await runProject({ cwd: root, command: 'build', mode: 'production' }); + expect(result.success).toBe(false); + expect(result.committed).toBe(false); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_STDIO_SMOKE_FAILED', phase: 'compile' })); + } + }); + + it('rejects local bundles that fail the MCP protocol smoke in both build modes', async () => { + /** mode 表示当前必须执行真实 initialize/tools-list 探测的构建模式。 */ + for (const mode of ['development', 'production'] as const) { + /** 立即退出且不响应 initialize 的无效本地实现。 */ + const root = await createProject({ + remote: false, + serverSource: 'process.exit(0);\n', + }); + /** 两种模式都必须在提交任何 Platform 产物前执行真实协议探测。 */ + const result = await runProject({ cwd: root, command: 'build', mode }); + expect(result.success).toBe(false); + expect(result.committed).toBe(false); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ + code: 'MCP_STDIO_SMOKE_FAILED', + phase: 'compile', + })); + await expect(fs.access(path.join(root, 'dist'))).rejects.toThrow(); + } + }); + + it('rejects unresolved runtime dynamic imports in local MCP bundles', async () => { + /** Rolldown 无法静态解析且会原样保留到运行时的动态 import。 */ + const root = await createProject({ + remote: false, + serverSource: 'await import(process.argv[2]);\n', + }); + /** 不完整模块图由 Extension build 阶段拒绝。 */ + const result = await runProject({ cwd: root, command: 'build', mode: 'production' }); + expect(result.success).toBe(false); + expect(result.committed).toBe(false); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ + code: 'BUILD_UNRESOLVED_IMPORT', + phase: 'compile', + })); + }); +}); diff --git a/packages/extensions/mcp/tsconfig.json b/packages/extensions/mcp/tsconfig.json new file mode 100644 index 0000000..3ae4da2 --- /dev/null +++ b/packages/extensions/mcp/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/extensions/mcp/tsdown.config.ts b/packages/extensions/mcp/tsdown.config.ts new file mode 100644 index 0000000..bcb3c54 --- /dev/null +++ b/packages/extensions/mcp/tsdown.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsdown'; +/** MCP Extension 骨架保持主包为 Peer Dependency。 */ +export default defineConfig({ + entry: './src/index.ts', + format: ['esm'], + platform: 'node', + target: 'node20', + dts: { generator: 'oxc' }, + clean: true, + sourcemap: false, + deps: { neverBundle: ['@tokenroll/acplugin'] }, +}); diff --git a/packages/extensions/mcp/vitest.config.ts b/packages/extensions/mcp/vitest.config.ts new file mode 100644 index 0000000..945b885 --- /dev/null +++ b/packages/extensions/mcp/vitest.config.ts @@ -0,0 +1,22 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +/** MCP 单测让公开主包与私有 Core 共享同一源码品牌实例。 */ +export default defineConfig({ + resolve: { + alias: [ + { + find: /^@tokenroll\/acplugin\/sdk$/, + replacement: fileURLToPath(new URL('../../acplugin/src/sdk.ts', import.meta.url)), + }, + { + find: /^@tokenroll\/acplugin$/, + replacement: fileURLToPath(new URL('../../acplugin/src/index.ts', import.meta.url)), + }, + { + find: /^@acplugin\/core$/, + replacement: fileURLToPath(new URL('../../core/src/index.ts', import.meta.url)), + }, + ], + }, +}); diff --git a/packages/platforms/antigravity/CHANGELOG.md b/packages/platforms/antigravity/CHANGELOG.md new file mode 100644 index 0000000..69a5248 --- /dev/null +++ b/packages/platforms/antigravity/CHANGELOG.md @@ -0,0 +1,26 @@ +# @tokenroll/acplugin-platform-antigravity + +## 0.0.3-beta + +### Major Changes + +- Add opaque, subject-bound Platform Component Contributions to the trusted Integration SDK. Core now transports strict JSON payloads and records scoped contributor provenance in BuildReport schema version 3 without acquiring Platform-specific Agent or target-format knowledge. + + Claude Code, Cursor, and OpenCode expose and render their own native Agent contribution payloads during Platform finalization. Codex, Antigravity, and Pi explicitly reject non-empty private component contributions rather than silently dropping them or generating fallback Skills. + + Harden `AssetService.fromBytes()` to accept only exact data-object inputs, exact generated-origin fields, and `string | Uint8Array` bytes so third-party Integrations cannot rely on accessor, hidden-field, or array-like coercion. + +### Patch Changes + +- Updated dependencies + - @tokenroll/acplugin@0.0.3-beta + +## 0.0.2-beta + +### Major Changes + +- 889da32: Rewrite the Antigravity Platform around the Package API, Core-owned Document codecs, deterministic Command/Agent fallback Skill identities, add-only Hooks/MCP Assets, final candidate validation, and explicit unsupported Node Runtime compatibility. + +### Patch Changes + +- Updated peer dependency on `@tokenroll/acplugin` to `^0.0.2-beta`. diff --git a/packages/platforms/antigravity/LICENSE b/packages/platforms/antigravity/LICENSE new file mode 100644 index 0000000..9113de7 --- /dev/null +++ b/packages/platforms/antigravity/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 TokenRollAI + +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/packages/platforms/antigravity/README.md b/packages/platforms/antigravity/README.md new file mode 100644 index 0000000..66c5694 --- /dev/null +++ b/packages/platforms/antigravity/README.md @@ -0,0 +1,27 @@ +# @tokenroll/acplugin-platform-antigravity + +Antigravity Platform package for `@tokenroll/acplugin`. + +```bash +pnpm add -D @tokenroll/acplugin @tokenroll/acplugin-platform-antigravity +``` + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import antigravity from '@tokenroll/acplugin-platform-antigravity'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [antigravity()], +}); +``` + +The package also exports the named `antigravity` factory, its option types, `PLATFORM_ID`, and `PLATFORM_API_VERSION`. + +Antigravity does not currently expose a Platform Component Contribution payload. A non-empty private component contribution fails during Package finalization rather than being silently converted to a Skill. + +## License + +MIT diff --git a/packages/platforms/antigravity/package.json b/packages/platforms/antigravity/package.json new file mode 100644 index 0000000..18ca3e7 --- /dev/null +++ b/packages/platforms/antigravity/package.json @@ -0,0 +1,29 @@ +{ + "name": "@tokenroll/acplugin-platform-antigravity", + "version": "0.0.3-beta", + "description": "Antigravity Platform integration for acplugin.", + "type": "module", + "license": "MIT", + "homepage": "https://github.com/TokenRollAI/acplugin#antigravity-platform", + "repository": { "type": "git", "url": "git+https://github.com/TokenRollAI/acplugin.git", "directory": "packages/platforms/antigravity" }, + "bugs": { "url": "https://github.com/TokenRollAI/acplugin/issues" }, + "sideEffects": false, + "engines": { "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, + "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" } }, + "files": ["dist", "README.md", "LICENSE"], + "publishConfig": { "access": "public" }, + "scripts": { + "build": "tsdown", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc -p tsconfig.json" + }, + "peerDependencies": { "@tokenroll/acplugin": "workspace:^" }, + "devDependencies": { + "@acplugin/core": "workspace:*", + "@tokenroll/acplugin": "workspace:^", + "@types/node": "catalog:", + "tsdown": "catalog:", + "@typescript/native": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/platforms/antigravity/src/index.ts b/packages/platforms/antigravity/src/index.ts new file mode 100644 index 0000000..d7ce415 --- /dev/null +++ b/packages/platforms/antigravity/src/index.ts @@ -0,0 +1,75 @@ +import { + definePlatform, + type AcpluginPlatform, +} from '@tokenroll/acplugin/sdk'; +import { + createAntigravityComponents, + validateAntigravityComponent, + validateGeneratedSkillIds, +} from './package/components.js'; +import { + createPluginDocument, + validatePlatformOptions, + type AntigravityPlatformOptions, +} from './package/manifest.js'; +import { validateAntigravityPackage } from './package/validator.js'; + +export type { AntigravityPlatformOptions } from './package/manifest.js'; + +/** Antigravity Platform 的稳定开放 ID。 */ +export const PLATFORM_ID = 'antigravity' as const; + +/** Antigravity Platform 实现的 Core API 版本。 */ +export const PLATFORM_API_VERSION = '1' as const; + +/** 创建只通过 Package API 交付 Antigravity Plugin 的 Platform。 */ +export function antigravity(options: AntigravityPlatformOptions = {}): AcpluginPlatform { + validatePlatformOptions(options); + return definePlatform({ + id: PLATFORM_ID, + apiVersion: PLATFORM_API_VERSION, + deliveryType: 'plugin', + ...(options.strict === undefined ? {} : { strict: options.strict }), + options: {}, + /** Antigravity 不声明 Node Runtime 能力,Core 对 Runtime 显式报告 unsupported。 */ + createSession: () => ({ + validateComponent: validateAntigravityComponent, + /** 在 Asset 创建前完成最终 Skill namespace 校验。 */ + async createPackage({ project, assets, diagnostics }) { + /** idsValid 防止 collision 诊断后继续签发有歧义的 Assets。 */ + const idsValid = validateGeneratedSkillIds(project, diagnostics); + /** components 只在最终命名空间无冲突时创建。 */ + const components = idsValid + ? await createAntigravityComponents(project, assets) + : { assets: Object.freeze([]), compatibility: Object.freeze([]) }; + /** manifest 始终使用 Core codec 生成最小官方 Document。 */ + const manifest = createPluginDocument(project.metadata); + return { + documents: [manifest.document], + assets: components.assets, + compatibility: components.compatibility, + metadata: manifest.metadata, + }; + }, + /** + * Antigravity 没有可验证的私有 Component wire contract。 + * + * Payload 必须在最终交付边界显式拒绝,避免无声丢弃或把 Agent 错误降级成 + * 生成 Skill;未来支持时仍应由本 Platform 自己引入 union 和 renderer。 + */ + finalizePackage: ({ package: mergedPackage, diagnostics }) => { + if (mergedPackage.components.length > 0) { + diagnostics.report({ + code: 'ANTIGRAVITY_COMPONENT_CONTRIBUTION_UNSUPPORTED', + severity: 'error', + message: 'Antigravity does not support Platform Component contributions.', + }); + } + return { id: 'plugin', type: 'plugin' }; + }, + validatePackage: validateAntigravityPackage, + }), + }); +} + +export default antigravity; diff --git a/packages/platforms/antigravity/src/package/components.ts b/packages/platforms/antigravity/src/package/components.ts new file mode 100644 index 0000000..f96dfe4 --- /dev/null +++ b/packages/platforms/antigravity/src/package/components.ts @@ -0,0 +1,193 @@ +import { + markdownWithFrontmatter, + type AssetService, + type CanonicalProject, + type CompatibilityInput, + type DiagnosticService, + type PackageAssetInput, + type PlatformComponentValidationContext, +} from '@tokenroll/acplugin/sdk'; + +/** Antigravity 当前不开放未经官方文档确认的 Component 专属字段。 */ +const COMPONENT_FIELDS = new Set(); + +/** 最终 Antigravity Skill 命名空间中的一项规范来源。 */ +interface GeneratedSkillIdentity { + readonly id: string; + readonly subject: string; +} + +/** Antigravity base Package 的 Component 转换结果。 */ +export interface AntigravityComponentPackage { + readonly assets: readonly PackageAssetInput[]; + readonly compatibility: readonly CompatibilityInput[]; +} + +/** 校验 Antigravity Component namespace,不允许 raw Frontmatter 逃逸。 */ +export function validateAntigravityComponent(context: PlatformComponentValidationContext): void { + /** fields 是 Scanner 已复制冻结的平台 namespace。 */ + const fields = context.component.platforms.antigravity ?? {}; + for (const field of Object.keys(fields)) { + if (!COMPONENT_FIELDS.has(field)) { + context.diagnostics.report({ + code: 'ANTIGRAVITY_COMPONENT_FIELD_UNKNOWN', + severity: 'error', + message: `Unknown Antigravity ${context.component.kind} field "${field}".`, + fieldPath: ['platforms', 'antigravity', field], + }); + } + } +} + +/** @returns 全部 canonical Component 最终占用的 Antigravity Skill identity。 */ +function generatedSkillIdentities(project: CanonicalProject): readonly GeneratedSkillIdentity[] { + return Object.freeze([ + ...project.skills.map(skill => Object.freeze({ id: skill.id, subject: `skill:${skill.id}` })), + ...project.commands.map(command => Object.freeze({ id: `command-${command.id}`, subject: `command:${command.id}` })), + ...project.agents.map(agent => Object.freeze({ id: `agent-${agent.id}`, subject: `agent:${agent.id}` })), + ]); +} + +/** 在任何 Asset 签发前拒绝 native/fallback Skill ID 的 exact、case 或 NFC 冲突。 */ +export function validateGeneratedSkillIds(project: CanonicalProject, diagnostics: DiagnosticService): boolean { + /** owners 使用最严格目标文件系统的 NFC/case-fold key。 */ + const owners = new Map(); + /** valid 允许调用方在命名空间有歧义时完全跳过 Asset 创建。 */ + let valid = true; + for (const identity of generatedSkillIdentities(project)) { + /** canonical ID 当前为 ASCII,显式规范化仍固定未来来源的边界。 */ + const key = identity.id.normalize('NFC').toLowerCase(); + /** owner 是先占用相同最终 ID 的规范来源。 */ + const owner = owners.get(key); + if (owner !== undefined) { + valid = false; + diagnostics.report({ + code: 'ANTIGRAVITY_GENERATED_SKILL_ID_COLLISION', + severity: 'error', + message: `${owner.subject} and ${identity.subject} both generate Antigravity Skill ID "${identity.id}".`, + hint: 'Rename one canonical Component so every native and fallback Skill ID is unique.', + }); + } else { + owners.set(key, identity); + } + } + return valid; +} + +/** 把 canonical Commands、Skills 与 Agents 转换为 Antigravity Skills。 */ +export async function createAntigravityComponents( + project: CanonicalProject, + assets: AssetService, +): Promise { + /** output 只包含 Platform 自有 bytes 和 Core 授权的 Skill auxiliary refs。 */ + const output: PackageAssetInput[] = []; + /** compatibility 精确描述每个 Component 的原生或 fallback 语义。 */ + const compatibility: CompatibilityInput[] = []; + for (const skill of project.skills) { + /** Skill 主文档使用 Antigravity 原生 Skill 结构。 */ + const asset = await assets.fromBytes({ + bytes: markdownWithFrontmatter({ name: skill.id, description: skill.description }, skill.body), + origin: { operation: 'component-skill', subjects: [`skill:${skill.id}`] }, + }); + output.push(Object.freeze({ path: `skills/${skill.id}/SKILL.md`, asset })); + for (const auxiliary of skill.auxiliaryFiles) + output.push(Object.freeze({ path: `skills/${skill.id}/${auxiliary.path}`, asset: auxiliary.asset })); + compatibility.push(Object.freeze({ + subject: `skill:${skill.id}`, + capability: 'component', + level: 'native', + reason: 'Antigravity Plugins support Skills natively.', + })); + if (!skill.invocation.user || !skill.invocation.model) { + compatibility.push(Object.freeze({ + subject: `skill:${skill.id}`, + capability: 'invocation', + level: 'degraded', + transformation: 'invocation-switches-omitted', + reason: 'Antigravity has no verified independent user and model invocation switches.', + })); + } + } + for (const command of project.commands) { + /** Command 使用固定前缀进入统一 Skill 命名空间。 */ + const id = `command-${command.id}`; + /** 显式 Skill 通过调用指引保留 Command 参数语义。 */ + const asset = await assets.fromBytes({ + bytes: markdownWithFrontmatter( + { name: id, description: command.description }, + command.body.replaceAll('{{arguments}}', 'the arguments supplied with this explicit invocation'), + ), + origin: { operation: 'component-command', subjects: [`command:${command.id}`] }, + }); + output.push(Object.freeze({ path: `skills/${id}/SKILL.md`, asset })); + compatibility.push(Object.freeze({ + subject: `command:${command.id}`, + capability: 'component', + level: 'transform', + transformation: `explicit-skill:${id}`.toLowerCase(), + reason: 'Antigravity Plugins expose reusable prompt workflows as Skills.', + })); + if (command.body.includes('{{arguments}}')) { + compatibility.push(Object.freeze({ + subject: `command:${command.id}`, + capability: 'arguments', + level: 'transform', + transformation: 'explicit-invocation-guidance', + reason: 'Antigravity Skills receive arguments through the invoking prompt.', + })); + } + if (command.argumentHint !== undefined) { + compatibility.push(Object.freeze({ + subject: `command:${command.id}`, + capability: 'argument-hint', + level: 'degraded', + transformation: 'argument-hint-omitted', + reason: 'Antigravity Skills have no verified Command argument hint field.', + })); + } + } + for (const agent of project.agents) { + /** Agent 使用固定前缀进入统一 Skill 命名空间。 */ + const id = `agent-${agent.id}`; + /** guidance 明确标注平台无法强制的模型和 capability 意图。 */ + const guidance = [ + agent.body, + '', + `Intended model class: ${agent.model}.`, + `Intended capabilities: ${agent.capabilities.join(', ') || 'none declared'}.`, + 'Use this Skill as role guidance; Antigravity does not register it as a dedicated Agent.', + ].join('\n'); + /** fallback Skill 由 Platform owner 签发。 */ + const asset = await assets.fromBytes({ + bytes: markdownWithFrontmatter({ name: id, description: agent.description }, guidance), + origin: { operation: 'component-agent', subjects: [`agent:${agent.id}`] }, + }); + output.push(Object.freeze({ path: `skills/${id}/SKILL.md`, asset })); + compatibility.push( + Object.freeze({ + subject: `agent:${agent.id}`, + capability: 'component', + level: 'degraded', + transformation: `guidance-skill:${id}`.toLowerCase(), + reason: 'Antigravity Plugin documentation does not define installable custom Agents.', + }), + Object.freeze({ + subject: `agent:${agent.id}`, + capability: 'agent.model', + level: 'degraded', + transformation: 'model-guidance', + reason: 'A fallback Skill cannot enforce an Agent model selection.', + }), + ); + if (agent.capabilities.length > 0) { + compatibility.push(Object.freeze({ + subject: `agent:${agent.id}`, + capability: 'agent.capabilities', + level: 'degraded', + transformation: 'capability-guidance', + reason: 'A fallback Skill cannot enforce an Agent capability boundary.', + })); + } + } + return Object.freeze({ assets: Object.freeze(output), compatibility: Object.freeze(compatibility) }); +} diff --git a/packages/platforms/antigravity/src/package/manifest.ts b/packages/platforms/antigravity/src/package/manifest.ts new file mode 100644 index 0000000..63c1a48 --- /dev/null +++ b/packages/platforms/antigravity/src/package/manifest.ts @@ -0,0 +1,74 @@ +import type { + JsonObject, + MetadataDispositionInput, + PackageDocumentInput, + PluginMetadata, +} from '@tokenroll/acplugin/sdk'; + +/** Antigravity Plugin 清单的稳定逻辑 Document ID。 */ +export const PLUGIN_MANIFEST_ID = 'plugin-manifest'; + +/** Antigravity Plugin 清单相对于安装根的固定路径。 */ +export const PLUGIN_MANIFEST_PATH = 'plugin.json'; + +/** 创建 Antigravity Platform 时可声明的公开选项。 */ +export interface AntigravityPlatformOptions { + readonly strict?: boolean; +} + +/** 校验 Antigravity Platform 只接受官方文档确认的最小选项。 */ +export function validatePlatformOptions(options: AntigravityPlatformOptions): void { + /** 当前只允许 Core strictness,不暴露猜测的 Manifest 字段。 */ + const allowed = new Set(['strict']); + for (const field of Object.keys(options)) { + if (!allowed.has(field)) + throw new TypeError(`Unknown Antigravity Platform option "${field}".`); + } + if (options.strict !== undefined && typeof options.strict !== 'boolean') + throw new TypeError('Antigravity strict must be a boolean.'); +} + +/** @returns 当前工程实际 metadata 的完整 emitted/omitted disposition。 */ +function metadataDispositions(metadata: PluginMetadata): readonly MetadataDispositionInput[] { + /** name 是公开契约中唯一确认的元数据字段。 */ + const outputs: [string, string | undefined][] = [['name', `${PLUGIN_MANIFEST_PATH}/name`]]; + /** version 与 description 必填但未被官方最小 Manifest 契约确认。 */ + outputs.push(['version', undefined], ['description', undefined]); + for (const field of ['displayName', 'homepage', 'repository', 'license'] as const) { + if (metadata[field] !== undefined) + outputs.push([field, undefined]); + } + if (metadata.author !== undefined) { + outputs.push(['author.name', undefined]); + if (metadata.author.email !== undefined) + outputs.push(['author.email', undefined]); + if (metadata.author.url !== undefined) + outputs.push(['author.url', undefined]); + } + if (metadata.keywords.length > 0) + outputs.push(['keywords', undefined]); + return Object.freeze(outputs.map(([field, output]) => Object.freeze({ + field, + disposition: output === undefined ? 'omitted' as const : 'emitted' as const, + ...(output === undefined ? {} : { output }), + reason: output === undefined + ? `Antigravity's public Plugin Manifest contract has not confirmed ${field}.` + : 'Antigravity plugin.json publicly documents the name field.', + }))); +} + +/** 创建只含官方确认 name 且由 Core codec 序列化的 Plugin Document。 */ +export function createPluginDocument(metadata: PluginMetadata): { + readonly document: PackageDocumentInput; + readonly metadata: readonly MetadataDispositionInput[]; +} { + /** Antigravity 不需要 Manifest 字段贡献,Hooks/MCP 通过固定根 Asset add-only 交付。 */ + const document: PackageDocumentInput = Object.freeze({ + id: PLUGIN_MANIFEST_ID, + path: PLUGIN_MANIFEST_PATH, + format: 'json', + value: { name: metadata.name } as JsonObject, + extensionPoints: Object.freeze([]), + }); + return Object.freeze({ document, metadata: metadataDispositions(metadata) }); +} diff --git a/packages/platforms/antigravity/src/package/validator.ts b/packages/platforms/antigravity/src/package/validator.ts new file mode 100644 index 0000000..bee232b --- /dev/null +++ b/packages/platforms/antigravity/src/package/validator.ts @@ -0,0 +1,176 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { JsonValue, ValidatePackageContext } from '@tokenroll/acplugin/sdk'; +import { PLUGIN_MANIFEST_PATH } from './manifest.js'; + +/** Antigravity validator 只消费 SDK 的最终 Package candidate Context。 */ +type PlatformValidateContext = ValidatePackageContext; + +/** Antigravity 当前验证过的 Hook 事件。 */ +const HOOK_EVENTS = new Set(['SessionStart', 'SessionEnd', 'PreToolUse', 'PostToolUse', 'PreCompact']); + +/** Antigravity Hook matcher 分组允许的字段。 */ +const HOOK_GROUP_FIELDS = new Set(['matcher', 'hooks']); + +/** Antigravity command Hook Handler 允许的字段。 */ +const HOOK_HANDLER_FIELDS = new Set(['type', 'command']); + +/** Antigravity 远程 MCP descriptor 允许的字段。 */ +const MCP_SERVER_FIELDS = new Set(['type', 'url', 'headers']); + +/** Extension 配置中的稳定 MCP Server ID。 */ +const MCP_SERVER_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + +/** JSON 对象的运行时只读索引类型。 */ +type JsonRecord = Record; + +/** @returns 未知 JSON 值是否为非数组对象。 */ +function isRecord(value: unknown): value is JsonRecord { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * 向 Core 提交 Antigravity 候选校验错误。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param code 稳定诊断码。 + * @param message 不包含宿主绝对路径的错误信息。 + */ +function report( + context: PlatformValidateContext, + code: string, + message: string, + fieldPath?: readonly (string | number)[], +): void { + context.diagnostics.report({ code, severity: 'error', message, ...(fieldPath === undefined ? {} : { fieldPath }) }); +} + +/** 校验 Antigravity 根 `hooks.json` 的完整命令协议。 */ +function validateHooks(context: PlatformValidateContext, value: unknown): void { + if (!isRecord(value) || Object.keys(value).some(field => field !== 'hooks') || !isRecord(value.hooks)) { + report(context, 'ANTIGRAVITY_HOOK_CONFIG_INVALID', 'hooks.json must contain only a hooks event mapping.', ['hooks']); + return; + } + for (const [event, groups] of Object.entries(value.hooks)) { + /** 当前事件在根 Hook 配置中的字段路径。 */ + const eventPath = ['hooks', event]; + if (!HOOK_EVENTS.has(event)) { + report(context, 'ANTIGRAVITY_HOOK_EVENT_UNKNOWN', `Unknown Antigravity Hook event "${event}".`, eventPath); + continue; + } + if (!Array.isArray(groups) || groups.length === 0) { + report(context, 'ANTIGRAVITY_HOOK_GROUPS_INVALID', 'Each Hook event must contain matcher groups.', eventPath); + continue; + } + for (const [groupIndex, group] of groups.entries()) { + /** 当前 matcher 分组的字段路径。 */ + const groupPath = [...eventPath, groupIndex]; + if (!isRecord(group)) { + report(context, 'ANTIGRAVITY_HOOK_GROUP_INVALID', 'Hook matcher groups must be objects.', groupPath); + continue; + } + for (const field of Object.keys(group)) { + if (!HOOK_GROUP_FIELDS.has(field)) + report(context, 'ANTIGRAVITY_HOOK_GROUP_FIELD_UNKNOWN', `Unknown Antigravity Hook group field "${field}".`, [...groupPath, field]); + } + if (group.matcher !== undefined && (typeof group.matcher !== 'string' || group.matcher.trim().length === 0)) + report(context, 'ANTIGRAVITY_HOOK_MATCHER_INVALID', 'Hook matcher must be a non-empty string.', [...groupPath, 'matcher']); + if (!Array.isArray(group.hooks) || group.hooks.length === 0) { + report(context, 'ANTIGRAVITY_HOOK_HANDLERS_INVALID', 'Hook groups must contain command handlers.', [...groupPath, 'hooks']); + continue; + } + for (const [handlerIndex, handler] of group.hooks.entries()) { + /** 单个 command Handler 的字段路径。 */ + const handlerPath = [...groupPath, 'hooks', handlerIndex]; + if (!isRecord(handler)) { + report(context, 'ANTIGRAVITY_HOOK_HANDLER_INVALID', 'Hook handlers must be objects.', handlerPath); + continue; + } + for (const field of Object.keys(handler)) { + if (!HOOK_HANDLER_FIELDS.has(field)) + report(context, 'ANTIGRAVITY_HOOK_HANDLER_FIELD_UNKNOWN', `Unknown Antigravity Hook handler field "${field}".`, [...handlerPath, field]); + } + if (handler.type !== 'command' || typeof handler.command !== 'string' || handler.command.trim().length === 0) + report(context, 'ANTIGRAVITY_HOOK_COMMAND_INVALID', 'Hook handlers must declare a non-empty command.', handlerPath); + } + } + } +} + +/** 校验 Antigravity 根 `mcp_config.json` 的 remote-only MCP 协议。 */ +function validateMcp(context: PlatformValidateContext, value: unknown): void { + if (!isRecord(value) || Object.keys(value).some(field => field !== 'mcpServers') || !isRecord(value.mcpServers)) { + report(context, 'ANTIGRAVITY_MCP_CONFIG_INVALID', 'mcp_config.json must contain only an mcpServers mapping.', ['mcpServers']); + return; + } + for (const [id, candidate] of Object.entries(value.mcpServers)) { + /** 当前 MCP Server 的字段路径。 */ + const serverPath = ['mcpServers', id]; + if (!MCP_SERVER_ID_PATTERN.test(id) || !isRecord(candidate)) { + report(context, 'ANTIGRAVITY_MCP_SERVER_INVALID', 'MCP Server ids must use lowercase kebab-case and map to objects.', serverPath); + continue; + } + for (const field of Object.keys(candidate)) { + if (!MCP_SERVER_FIELDS.has(field)) + report(context, 'ANTIGRAVITY_MCP_FIELD_UNKNOWN', `Unknown Antigravity MCP field "${field}".`, [...serverPath, field]); + } + if (candidate.type !== 'http') + report(context, 'ANTIGRAVITY_MCP_TRANSPORT_INVALID', 'Antigravity MCP Server type must be http.', [...serverPath, 'type']); + if (typeof candidate.url !== 'string') { + report(context, 'ANTIGRAVITY_MCP_URL_INVALID', 'MCP url must be an HTTP(S) URL without credentials.', [...serverPath, 'url']); + } else { + try { + /** 远程 MCP URL 不得包含用户信息。 */ + const url = new URL(candidate.url); + if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username !== '' || url.password !== '') + throw new TypeError('unsafe'); + } catch { + report(context, 'ANTIGRAVITY_MCP_URL_INVALID', 'MCP url must be an HTTP(S) URL without credentials.', [...serverPath, 'url']); + } + } + if (candidate.headers !== undefined + && (!isRecord(candidate.headers) + || Object.entries(candidate.headers).some(([key, header]) => key.trim().length === 0 || typeof header !== 'string'))) { + report(context, 'ANTIGRAVITY_MCP_HEADERS_INVALID', 'MCP headers must map non-empty names to string values.', [...serverPath, 'headers']); + } + } +} + +/** + * 校验 Antigravity 最小 Manifest、Skill 目录和可选 Extension 配置。 + * + * @param context Platform 提供的已物化候选交付单元。 + */ +export async function validateAntigravityPackage(context: PlatformValidateContext): Promise { + /** 当前候选 Package 的规范 Asset 路径集合。 */ + const assets = new Set(context.candidate.unit.assets.map(asset => asset.path)); + try { + /** 当前没有公开 Schema,内部严格规则只接受官方文档确认的 name。 */ + const value: unknown = JSON.parse(await fs.readFile(path.join(context.candidate.root, PLUGIN_MANIFEST_PATH), 'utf8')); + if (value === null || typeof value !== 'object' || Array.isArray(value)) + throw new TypeError('Manifest is not an object.'); + /** 经过对象形态检查的最小 Manifest。 */ + const manifest = value as Record; + if (Object.keys(manifest).length !== 1 || typeof manifest.name !== 'string' || manifest.name.trim().length === 0) + report(context, 'ANTIGRAVITY_MANIFEST_INVALID', 'plugin.json must contain exactly one non-empty name field.'); + } catch { + report(context, 'ANTIGRAVITY_MANIFEST_READ_FAILED', 'plugin.json must contain the documented minimal JSON object.'); + } + /** path 表示当前可选平台配置,存在时必须满足对应完整协议。 */ + for (const assetPath of ['hooks.json', 'mcp_config.json']) { + if (!assets.has(assetPath)) + continue; + try { + /** Extension 配置由其 Contributor 生成,但仍由 Platform 做最终协议校验。 */ + const value: unknown = JSON.parse(await fs.readFile(path.join(context.candidate.root, assetPath), 'utf8')); + if (assetPath === 'hooks.json') + validateHooks(context, value); + else + validateMcp(context, value); + } catch { + report(context, 'ANTIGRAVITY_EXTENSION_CONFIG_INVALID', `${assetPath} must contain a JSON object.`); + } + } + if ([...assets].some(asset => asset.startsWith('commands/') || asset.startsWith('agents/'))) + report(context, 'ANTIGRAVITY_UNDOCUMENTED_RESOURCE', 'Commands and Agents must be transformed into the documented skills/ tree.'); +} diff --git a/packages/platforms/antigravity/test/golden/plugin.json b/packages/platforms/antigravity/test/golden/plugin.json new file mode 100644 index 0000000..cfc323d --- /dev/null +++ b/packages/platforms/antigravity/test/golden/plugin.json @@ -0,0 +1,3 @@ +{ + "name": "release-tools" +} diff --git a/packages/platforms/antigravity/test/golden/plugin.schema.json b/packages/platforms/antigravity/test/golden/plugin.schema.json new file mode 100644 index 0000000..9f40714 --- /dev/null +++ b/packages/platforms/antigravity/test/golden/plugin.schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$comment": "Internal strict fixture: Antigravity has no published Plugin JSON Schema as verified on 2026-08-06.", + "type": "object", + "required": [ + "name" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1 + } + } +} diff --git a/packages/platforms/antigravity/test/platform.test.ts b/packages/platforms/antigravity/test/platform.test.ts new file mode 100644 index 0000000..656696a --- /dev/null +++ b/packages/platforms/antigravity/test/platform.test.ts @@ -0,0 +1,350 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + defineExtension, + resolveKernelConfig, + runKernelBuildSession, + type AcpluginExtension, + type PlatformContributor, +} from '@acplugin/core'; +import { antigravity } from '../src/index.js'; +import { PLUGIN_MANIFEST_PATH } from '../src/package/manifest.js'; + +/** 测试结束后统一删除的临时工程根目录。 */ +const temporaryRoots: string[] = []; + +/** 内部严格 Schema 和 Manifest Golden 的固定目录。 */ +const goldenRoot = path.join(import.meta.dirname, 'golden'); + +/** 测试只读取的 Antigravity 内部 Schema 结构。 */ +interface AntigravitySchemaFixture { + /** 内部规则是否禁止未确认字段。 */ + readonly additionalProperties: boolean; + /** 内部规则要求的最小字段。 */ + readonly required: readonly string[]; + /** 允许的唯一根字段定义。 */ + readonly properties: Readonly>; +} + +/** 创建包含最小配置占位符且登记清理的工程。 */ +async function temporaryProject(): Promise { + /** 当前用例独占的工程根目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-antigravity-platform-')); + temporaryRoots.push(root); + await fs.mkdir(path.join(root, 'src'), { recursive: true }); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), 'export default {}\n'); + return root; +} + +/** 写入原生 Skill、转换 Command、fallback Agent 和 Skill 辅助文件。 */ +async function writeCompleteProject(root: string): Promise { + await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/skills/review/references'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/agents'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/commands/release.md'), `--- +description: Prepare a release. +argumentHint: environment +--- +Prepare release {{arguments}}. +`); + await fs.writeFile(path.join(root, 'src/skills/review/SKILL.md'), '---\ndescription: Review a change.\n---\nReview the change.\n'); + await fs.writeFile(path.join(root, 'src/skills/review/references/checklist.md'), 'Review checklist.\n'); + await fs.writeFile(path.join(root, 'src/agents/reviewer.md'), `--- +description: Review code. +model: capable +capabilities: [filesystem:read, search] +--- +Review code. +`); +} + +/** 执行只包含 Antigravity 的真实 Kernel v2 BuildSession。 */ +async function run(input: { + readonly root: string; + readonly platform?: ReturnType; + readonly extensions?: readonly AcpluginExtension[]; + readonly command?: 'validate' | 'inspect' | 'build'; + readonly commit?: boolean; +}) { + /** command 决定生命周期语义,commit 只允许 build 使用。 */ + const command = input.command ?? 'build'; + /** resolved 使用公开配置相同的 Kernel resolver。 */ + const resolved = resolveKernelConfig({ + name: 'release-tools', + version: '1.2.3', + description: 'Release workflow tools.', + public: false, + platforms: [input.platform ?? antigravity()], + extensions: input.extensions ?? [], + }, { + projectRoot: input.root, + configFile: path.join(input.root, 'acplugin.config.ts'), + command, + mode: 'production', + }); + expect(resolved.diagnostics).toEqual([]); + return (await runKernelBuildSession({ + config: resolved.config!, + frameworkVersion: 'test', + commit: command === 'build' && (input.commit ?? true), + })).report; +} + +/** 创建向 Antigravity 根 Package 追加固定配置 Asset 的测试 Extension。 */ +function rootContribution(input: { + readonly id: string; + readonly path: string; + readonly bytes: string; +}): AcpluginExtension { + return defineExtension({ + id: input.id, + apiVersion: '1', + resourceRoots: [], + /** 每轮创建独立的测试 Session。 */ + createSession: () => ({ + /** 空状态表示 Fixture 已发现。 */ + discover: () => ({}), + /** capability 声明要求 Contributor 完整覆盖。 */ + validate: (_context, state) => ({ + state, + subjects: [{ subject: `fixture:${input.id}`, capabilities: ['delivery'] }], + }), + /** bytes 只通过 Extension owner-scoped Asset Service 签发。 */ + build: async ({ assets }, state) => ({ + state: { + state, + asset: await assets.fromBytes({ + bytes: input.bytes, + origin: { operation: 'antigravity-fixture', subjects: [`fixture:${input.id}`] }, + }), + }, + }), + contributors: [{ + platform: 'antigravity', + platformApiVersion: '1', + /** Contributor 只追加自己的根 Asset 并覆盖自己的 tuple。 */ + contribute: (_context, built) => ({ + assets: [{ path: input.path, asset: built.asset }], + compatibility: [{ + subject: `fixture:${input.id}`, + capability: 'delivery', + level: 'native', + reason: 'The fixture is delivered through the Antigravity Package contribution contract.', + }], + }), + }], + }), + }); +} + +/** 创建 Antigravity 必须显式拒绝的非空私有 Component contribution。 */ +function unsupportedComponentContribution(): AcpluginExtension { + const contributor: PlatformContributor, { readonly kind: 'fixture-component' }> = { + platform: 'antigravity', + platformApiVersion: '1', + contribute: () => ({ + components: [{ subject: 'fixture:private-component', value: { kind: 'fixture-component' } }], + compatibility: [{ + subject: 'fixture:private-component', capability: 'delivery', level: 'native', + reason: 'The fixture requests private Component delivery.', + }], + }), + }; + return defineExtension({ + id: 'private-component-fixture', + apiVersion: '1', + resourceRoots: [], + createSession: () => ({ + discover: () => ({}), + validate: (_context, state) => ({ + state, subjects: [{ subject: 'fixture:private-component', capabilities: ['delivery'] }], + }), + build: (_context, state) => ({ state }), + contributors: [contributor], + }), + }); +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('Antigravity Platform Package API', () => { + it('emits only the documented manifest and converts all Components into the Skill tree', async () => { + /** root 包含 native、transform 和 degraded 三类 Component。 */ + const root = await temporaryProject(); + await writeCompleteProject(root); + /** relaxed 允许已明确报告的 Agent/argumentHint/invocation 降级。 */ + const report = await run({ root, platform: antigravity({ strict: false }) }); + /** 内部严格 Schema Fixture。 */ + const schema = JSON.parse(await fs.readFile(path.join(goldenRoot, 'plugin.schema.json'), 'utf8')) as AntigravitySchemaFixture; + /** output 是 Antigravity 主 Plugin 根。 */ + const output = path.join(root, 'dist/antigravity/plugin'); + /** manifest 是 Core codec 物化并通过最终 validator 的对象。 */ + const manifest = JSON.parse(await fs.readFile(path.join(output, PLUGIN_MANIFEST_PATH), 'utf8')) as Record; + + expect(report.success, JSON.stringify(report.diagnostics, null, 2)).toBe(true); + expect(schema.additionalProperties).toBe(false); + expect(Object.keys(manifest)).toEqual(schema.required); + expect(Object.keys(manifest).every(field => Object.hasOwn(schema.properties, field))).toBe(true); + await expect(fs.readFile(path.join(output, PLUGIN_MANIFEST_PATH))).resolves.toEqual( + await fs.readFile(path.join(goldenRoot, PLUGIN_MANIFEST_PATH)), + ); + await expect(fs.readFile(path.join(output, 'skills/review/references/checklist.md'), 'utf8')).resolves.toBe('Review checklist.\n'); + await expect(fs.readFile(path.join(output, 'skills/command-release/SKILL.md'), 'utf8')) + .resolves.toContain('the arguments supplied with this explicit invocation'); + await expect(fs.readFile(path.join(output, 'skills/agent-reviewer/SKILL.md'), 'utf8')) + .resolves.toContain('Intended model class: capable.'); + await expect(fs.access(path.join(output, 'commands'))).rejects.toThrow(); + await expect(fs.access(path.join(output, 'agents'))).rejects.toThrow(); + expect(report.compatibility).toEqual(expect.arrayContaining([ + expect.objectContaining({ subject: 'skill:review', capability: 'component', level: 'native' }), + expect.objectContaining({ + subject: 'command:release', capability: 'component', level: 'transform', + transformation: 'explicit-skill:command-release', + }), + expect.objectContaining({ + subject: 'agent:reviewer', capability: 'component', level: 'degraded', + transformation: 'guidance-skill:agent-reviewer', + }), + ])); + expect(report.metadata).toContainEqual(expect.objectContaining({ field: 'version', disposition: 'omitted' })); + }); + + it('rejects native/fallback Skill identity collisions before Package creation', async () => { + /** root 的 native Skill 占用 Command 最终生成的固定 ID。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/skills/command-release'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/commands/release.md'), '---\ndescription: Release.\n---\nRelease.\n'); + await fs.writeFile(path.join(root, 'src/skills/command-release/SKILL.md'), '---\ndescription: Existing.\n---\nExisting.\n'); + /** report 必须在任何有歧义的 Skill Asset 签发前失败。 */ + const report = await run({ root, command: 'validate', commit: false }); + + expect(report.success).toBe(false); + expect(report.diagnostics).toContainEqual(expect.objectContaining({ + code: 'ANTIGRAVITY_GENERATED_SKILL_ID_COLLISION', platform: 'antigravity', phase: 'package', + })); + expect(report.packages).toEqual([]); + }); + + it('accepts Hooks/MCP root contributions and rejects reserved path collisions', async () => { + /** validRoot 只需要一个原生 Skill 作为 Plugin host。 */ + const validRoot = await temporaryProject(); + await fs.mkdir(path.join(validRoot, 'src/skills/host'), { recursive: true }); + await fs.writeFile(path.join(validRoot, 'src/skills/host/SKILL.md'), '---\ndescription: Host.\n---\nHost.\n'); + /** hooks 和 mcp 通过固定根路径 add-only 交付。 */ + const hooks = rootContribution({ id: 'hooks-fixture', path: 'hooks.json', bytes: '{"hooks":{}}\n' }); + /** mcp 是独立 owner 的第二个根 Asset。 */ + const mcp = rootContribution({ id: 'mcp-fixture', path: 'mcp_config.json', bytes: '{"mcpServers":{}}\n' }); + /** valid 必须通过 Antigravity 最终 JSON 对象校验。 */ + const valid = await run({ root: validRoot, extensions: [hooks, mcp] }); + expect(valid.success, JSON.stringify(valid.diagnostics, null, 2)).toBe(true); + expect(valid.packages[0]?.assets).toEqual(expect.arrayContaining([ + expect.objectContaining({ path: 'hooks.json', owner: 'extension:hooks-fixture' }), + expect.objectContaining({ path: 'mcp_config.json', owner: 'extension:mcp-fixture' }), + ])); + + /** collisionRoot 的 Extension 试图占用 Platform Document 保留路径。 */ + const collisionRoot = await temporaryProject(); + await fs.mkdir(path.join(collisionRoot, 'src/skills/host'), { recursive: true }); + await fs.writeFile(path.join(collisionRoot, 'src/skills/host/SKILL.md'), '---\ndescription: Host.\n---\nHost.\n'); + /** collision 由 Core Package path Registry 拒绝,不依赖 Contributor 顺序。 */ + const collision = await run({ + root: collisionRoot, + command: 'validate', + extensions: [rootContribution({ id: 'reserved-path', path: 'plugin.json', bytes: '{}\n' })], + commit: false, + }); + expect(collision.success).toBe(false); + expect(collision.diagnostics).toContainEqual(expect.objectContaining({ + code: 'PLATFORM_CONTRIBUTION_FAILED', platform: 'antigravity', phase: 'contribute', + })); + }); + + it('explicitly rejects non-empty private Component contributions', async () => { + const root = await temporaryProject(); + const report = await run({ + root, command: 'validate', commit: false, extensions: [unsupportedComponentContribution()], + }); + + expect(report.success).toBe(false); + expect(report.packages).toEqual([]); + expect(report.diagnostics).toContainEqual(expect.objectContaining({ + code: 'ANTIGRAVITY_COMPONENT_CONTRIBUTION_UNSUPPORTED', phase: 'finalize', platform: 'antigravity', + })); + }); + + it('rejects malformed merged Extension configuration at the candidate boundary', async () => { + /** root 包含合法 base Skill,错误只来自 Contribution wire bytes。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/skills/host'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/skills/host/SKILL.md'), '---\ndescription: Host.\n---\nHost.\n'); + /** malformed hooks.json 是合法 JSON 但不是平台要求的对象。 */ + const malformed = rootContribution({ id: 'malformed-hooks', path: 'hooks.json', bytes: '[]\n' }); + /** report 必须由最终 Antigravity validator 产生稳定诊断。 */ + const report = await run({ root, command: 'validate', extensions: [malformed], commit: false }); + + expect(report.success).toBe(false); + expect(report.diagnostics).toContainEqual(expect.objectContaining({ + code: 'ANTIGRAVITY_HOOK_CONFIG_INVALID', platform: 'antigravity', phase: 'platform-validate', + })); + + /** mcpRoot 验证合法 sidecar 容器中的嵌套 Server 字段。 */ + const mcpRoot = await temporaryProject(); + /** invalidMcp 的 headers 不是 Antigravity 协议要求的字符串映射。 */ + const invalidMcp = rootContribution({ + id: 'malformed-mcp', path: 'mcp_config.json', + bytes: '{"mcpServers":{"docs":{"type":"http","url":"https://example.com/mcp","headers":42}}}\n', + }); + /** mcpReport 必须由最终 Candidate validator 拒绝。 */ + const mcpReport = await run({ root: mcpRoot, command: 'validate', extensions: [invalidMcp], commit: false }); + expect(mcpReport.success).toBe(false); + expect(mcpReport.diagnostics).toContainEqual(expect.objectContaining({ + code: 'ANTIGRAVITY_MCP_HEADERS_INVALID', platform: 'antigravity', phase: 'platform-validate', + })); + }); + + it('reports unsupported Runtime without compiling or generating fake assets', async () => { + /** Runtime import 若进入 portable-node 必然失败,用于证明 capability 协商发生在 compile 前。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/skills/host'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/runtime'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/skills/host/SKILL.md'), '---\ndescription: Host.\n---\nHost.\n'); + await fs.writeFile(path.join(root, 'src/runtime/cli.ts'), 'import "missing-runtime-package";\n'); + /** relaxed 接受已报告的 Runtime capability 差异。 */ + const report = await run({ root, platform: antigravity({ strict: false }) }); + + expect(report.success, JSON.stringify(report.diagnostics, null, 2)).toBe(true); + expect(report.runtimes).toEqual([{ + id: 'cli', kind: 'executable', location: { path: 'src/runtime/cli.ts' }, built: false, + }]); + expect(report.compatibility).toContainEqual(expect.objectContaining({ + platform: 'antigravity', subject: 'runtime:cli', capability: 'node20-esm', level: 'unsupported', + })); + expect(report.packages.flatMap(unit => unit.assets).some(asset => asset.path.startsWith('runtime/'))).toBe(false); + }); + + it('rejects unknown factory and Component fields without raw escape hatches', async () => { + expect(() => antigravity({ manifest: {} } as never)).toThrow('Unknown Antigravity Platform option'); + /** root 的平台 namespace 包含未公开字段。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/skills/invalid'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/skills/invalid/SKILL.md'), `--- +description: Invalid field. +platforms: + antigravity: + raw: true +--- +Do not build. +`); + /** report 应保留 canonical namespace fieldPath。 */ + const report = await run({ root, command: 'validate', commit: false }); + expect(report.diagnostics).toContainEqual(expect.objectContaining({ + code: 'ANTIGRAVITY_COMPONENT_FIELD_UNKNOWN', + fieldPath: ['platforms', 'antigravity', 'raw'], + })); + }); +}); diff --git a/packages/platforms/antigravity/tsconfig.json b/packages/platforms/antigravity/tsconfig.json new file mode 100644 index 0000000..3ae4da2 --- /dev/null +++ b/packages/platforms/antigravity/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/platforms/antigravity/tsdown.config.ts b/packages/platforms/antigravity/tsdown.config.ts new file mode 100644 index 0000000..98b8301 --- /dev/null +++ b/packages/platforms/antigravity/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsdown'; + +/** Antigravity Platform 使用统一 Node 20 ESM 与声明输出。 */ +export default defineConfig({ + entry: ['./src/index.ts'], + format: ['esm'], + platform: 'node', + target: 'node20', + dts: { generator: 'oxc' }, + clean: true, + sourcemap: false, + deps: { neverBundle: ['@tokenroll/acplugin'] }, +}); diff --git a/packages/platforms/antigravity/vitest.config.ts b/packages/platforms/antigravity/vitest.config.ts new file mode 100644 index 0000000..c8e4c90 --- /dev/null +++ b/packages/platforms/antigravity/vitest.config.ts @@ -0,0 +1,22 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +/** Antigravity 单测让公开主包与私有 Core 共享同一源码品牌实例。 */ +export default defineConfig({ + resolve: { + alias: [ + { + find: /^@tokenroll\/acplugin\/sdk$/, + replacement: fileURLToPath(new URL('../../acplugin/src/sdk.ts', import.meta.url)), + }, + { + find: /^@tokenroll\/acplugin$/, + replacement: fileURLToPath(new URL('../../acplugin/src/index.ts', import.meta.url)), + }, + { + find: /^@acplugin\/core$/, + replacement: fileURLToPath(new URL('../../core/src/index.ts', import.meta.url)), + }, + ], + }, +}); diff --git a/packages/platforms/claude-code/CHANGELOG.md b/packages/platforms/claude-code/CHANGELOG.md new file mode 100644 index 0000000..663c435 --- /dev/null +++ b/packages/platforms/claude-code/CHANGELOG.md @@ -0,0 +1,26 @@ +# @tokenroll/acplugin-platform-claude-code + +## 0.0.3-beta + +### Major Changes + +- Add opaque, subject-bound Platform Component Contributions to the trusted Integration SDK. Core now transports strict JSON payloads and records scoped contributor provenance in BuildReport schema version 3 without acquiring Platform-specific Agent or target-format knowledge. + + Claude Code, Cursor, and OpenCode expose and render their own native Agent contribution payloads during Platform finalization. Codex, Antigravity, and Pi explicitly reject non-empty private component contributions rather than silently dropping them or generating fallback Skills. + + Harden `AssetService.fromBytes()` to accept only exact data-object inputs, exact generated-origin fields, and `string | Uint8Array` bytes so third-party Integrations cannot rely on accessor, hidden-field, or array-like coercion. + +### Patch Changes + +- Updated dependencies + - @tokenroll/acplugin@0.0.3-beta + +## 0.0.2-beta + +### Major Changes + +- 889da32: Rewrite the Claude Code Platform around the Package API, Core-owned Document codecs, native Command/Skill/Agent delivery, add-only Hooks/MCP extension points, capability-negotiated Core Node Runtime delivery, final candidate validation, and validated primary Asset inheritance for Marketplace distributions. + +### Patch Changes + +- Updated peer dependency on `@tokenroll/acplugin` to `^0.0.2-beta`. diff --git a/packages/platforms/claude-code/LICENSE b/packages/platforms/claude-code/LICENSE new file mode 100644 index 0000000..9113de7 --- /dev/null +++ b/packages/platforms/claude-code/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 TokenRollAI + +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/packages/platforms/claude-code/README.md b/packages/platforms/claude-code/README.md new file mode 100644 index 0000000..30a1848 --- /dev/null +++ b/packages/platforms/claude-code/README.md @@ -0,0 +1,27 @@ +# @tokenroll/acplugin-platform-claude-code + +Claude Code Platform package for `@tokenroll/acplugin`. + +```bash +pnpm add -D @tokenroll/acplugin @tokenroll/acplugin-platform-claude-code +``` + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [claudeCode()], +}); +``` + +The package also exports the named `claudeCode` factory, its option types, `ClaudePackageComponent`, `ClaudeNativeAgentComponent`, `PLATFORM_ID`, and `PLATFORM_API_VERSION`. + +`ClaudePackageComponent` is for trusted Extension contributors that need Claude-native private delivery. It is not a Canonical Agent or a raw Manifest escape hatch: Claude Code validates and renders it during Package finalization. + +## License + +MIT diff --git a/packages/platforms/claude-code/package.json b/packages/platforms/claude-code/package.json new file mode 100644 index 0000000..b7b3b52 --- /dev/null +++ b/packages/platforms/claude-code/package.json @@ -0,0 +1,29 @@ +{ + "name": "@tokenroll/acplugin-platform-claude-code", + "version": "0.0.3-beta", + "description": "Claude Code Platform integration for acplugin.", + "type": "module", + "license": "MIT", + "homepage": "https://github.com/TokenRollAI/acplugin#claude-code-platform", + "repository": { "type": "git", "url": "git+https://github.com/TokenRollAI/acplugin.git", "directory": "packages/platforms/claude-code" }, + "bugs": { "url": "https://github.com/TokenRollAI/acplugin/issues" }, + "sideEffects": false, + "engines": { "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, + "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" } }, + "files": ["dist", "README.md", "LICENSE"], + "publishConfig": { "access": "public" }, + "scripts": { + "build": "tsdown", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc -p tsconfig.json" + }, + "peerDependencies": { "@tokenroll/acplugin": "workspace:^" }, + "devDependencies": { + "@acplugin/core": "workspace:*", + "@tokenroll/acplugin": "workspace:^", + "@types/node": "catalog:", + "tsdown": "catalog:", + "@typescript/native": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/platforms/claude-code/src/index.ts b/packages/platforms/claude-code/src/index.ts new file mode 100644 index 0000000..caf8d2a --- /dev/null +++ b/packages/platforms/claude-code/src/index.ts @@ -0,0 +1,241 @@ +import { + definePlatform, + type AcpluginPlatform, + type ContributedPackageComponent, + type JsonObject, +} from '@tokenroll/acplugin/sdk'; +import { + claudeNativeAgentDocument, + createClaudeComponents, + renderClaudeAgent, + validateClaudeComponent, +} from './package/components.js'; +import { + createMarketplaceAssets, + createPluginDocument, + validatePlatformOptions, +} from './package/manifest.js'; +import type { + ClaudeCodeMarketplaceOptions, + ClaudeCodePlatformOptions, + ClaudeNativeAgentComponent, + ClaudePackageComponent, +} from './types.js'; +import { validateClaudePackage } from './package/validation/index.js'; + +export type { + ClaudeCodeMarketplaceOptions, + ClaudeCodeMarketplaceOwner, + ClaudeCodePlatformOptions, + ClaudeNativeAgentComponent, + ClaudePackageComponent, +} from './types.js'; + +/** Claude Code Platform 的稳定开放 ID。 */ +export const PLATFORM_ID = 'claude-code' as const; + +/** Claude Code Platform 实现的 Core API 版本。 */ +export const PLATFORM_API_VERSION = '1' as const; + +/** 创建只通过 Package API 交付 Claude Code Plugin 的 Platform。 */ +const STABLE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + +/** Platform-owned payload errors remain distinguishable from unexpected implementation failures. */ +class ClaudeComponentContributionError extends Error { + constructor( + readonly category: 'invalid' | 'collision', + message: string, + ) { + super(message); + this.name = 'ClaudeComponentContributionError'; + } +} + +/** @returns 是否为 non-empty stable single-line text。 */ +function nonEmptyText(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0 && !/[\r\n\t\0]/u.test(value); +} + +/** @returns 是否为 unique non-empty string array。 */ +function stringArray(value: unknown): value is readonly string[] { + return Array.isArray(value) && value.every(nonEmptyText) && new Set(value).size === value.length; +} + +/** 为 Platform 的私有 Agent schema 建立精确 data-object 边界。 */ +function nativeAgent(value: unknown): ClaudeNativeAgentComponent { + if (typeof value !== 'object' || value === null || Array.isArray(value) + || (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) + || Object.getOwnPropertySymbols(value).length > 0) { + throw new TypeError('Claude Code Platform Component must be a plain object.'); + } + const allowed = new Set(['kind', 'id', 'description', 'body', 'model', 'tools', 'disallowedTools', 'effort', 'maxTurns', 'skills', 'memory', 'background', 'isolation']); + const fields = Object.getOwnPropertyDescriptors(value); + for (const [field, descriptor] of Object.entries(fields)) { + if (!allowed.has(field)) + throw new TypeError(`Unknown Claude Code Platform Component field "${field}".`); + if (!('value' in descriptor) || descriptor.enumerable !== true) + throw new TypeError(`Claude Code Platform Component.${field} must be an enumerable data property.`); + } + const input = value as Record; + if (input.kind !== 'native-agent') + throw new TypeError('Claude Code Platform Component kind must be native-agent.'); + if (typeof input.id !== 'string' || !STABLE_ID.test(input.id)) + throw new TypeError('Claude Code Platform Component id must use lowercase kebab-case.'); + if (!nonEmptyText(input.description)) + throw new TypeError('Claude Code Platform Component description must be a non-empty stable single-line string.'); + if (typeof input.body !== 'string' || input.body.trim().length === 0) + throw new TypeError('Claude Code Platform Component body must be non-empty.'); + if (input.model !== undefined && input.model !== 'inherit' && input.model !== 'fast' && input.model !== 'capable') + throw new TypeError('Claude Code Platform Component model is invalid.'); + if (input.tools !== undefined && !stringArray(input.tools)) + throw new TypeError('Claude Code Platform Component tools must contain unique non-empty strings.'); + if (input.disallowedTools !== undefined && !stringArray(input.disallowedTools)) + throw new TypeError('Claude Code Platform Component disallowedTools must contain unique non-empty strings.'); + if (input.skills !== undefined && !stringArray(input.skills)) + throw new TypeError('Claude Code Platform Component skills must contain unique non-empty strings.'); + if (input.effort !== undefined && !['low', 'medium', 'high', 'xhigh', 'max'].includes(String(input.effort))) + throw new TypeError('Claude Code Platform Component effort is invalid.'); + if (input.maxTurns !== undefined && (!Number.isInteger(input.maxTurns) || Number(input.maxTurns) <= 0)) + throw new TypeError('Claude Code Platform Component maxTurns must be a positive integer.'); + if (input.memory !== undefined && !['user', 'project', 'local'].includes(String(input.memory))) + throw new TypeError('Claude Code Platform Component memory is invalid.'); + if (input.background !== undefined && typeof input.background !== 'boolean') + throw new TypeError('Claude Code Platform Component background must be boolean.'); + if (input.isolation !== undefined && input.isolation !== 'worktree') + throw new TypeError('Claude Code Platform Component isolation is invalid.'); + return input as ClaudeNativeAgentComponent; +} + +/** 保守归一化 Claude Agent output path 的大小写/NFC collision identity。 */ +function agentCollisionKey(id: string): string { + return id.normalize('NFC').toLowerCase(); +} + +/** Platform finalization 解析并渲染其私有 Native Agent contributions。 */ +async function contributedAgents( + components: readonly ContributedPackageComponent[], + canonicalIds: readonly string[], + assets: import('@tokenroll/acplugin/sdk').FinalizationAssetService, +): Promise<{ readonly assets: readonly import('@tokenroll/acplugin/sdk').PackageAssetInput[]; readonly origins: readonly import('@tokenroll/acplugin/sdk').PackageComponentOrigin[] }> { + const occupied = new Map(canonicalIds.map(id => [agentCollisionKey(id), `canonical Agent "${id}"`])); + let parsed: readonly { readonly component: ClaudeNativeAgentComponent; readonly origin: import('@tokenroll/acplugin/sdk').PackageComponentOrigin }[]; + try { + parsed = components.map(component => Object.freeze({ component: nativeAgent(component.value), origin: component.origin })); + } catch (error) { + if (!(error instanceof TypeError)) + throw error; + throw new ClaudeComponentContributionError('invalid', error.message); + } + /** Core order无关;Platform 仍按自己的 collision domain 验证全部 component。 */ + for (const { component } of parsed) { + const key = agentCollisionKey(component.id); + const existing = occupied.get(key); + if (existing !== undefined) { + throw new ClaudeComponentContributionError( + 'collision', + 'Claude Code Native Agent "' + component.id + '" collides with ' + existing + '.', + ); + } + occupied.set(key, `contributed Native Agent "${component.id}"`); + } + const output: import('@tokenroll/acplugin/sdk').PackageAssetInput[] = []; + const origins: import('@tokenroll/acplugin/sdk').PackageComponentOrigin[] = []; + for (const { component, origin } of [...parsed].sort((left, right) => left.component.id < right.component.id ? -1 : left.component.id > right.component.id ? 1 : 0)) { + const asset = await assets.fromBytes({ + bytes: renderClaudeAgent(claudeNativeAgentDocument(component)), + origin: { operation: 'platform-component-agent', subjects: [origin.subject], componentOrigins: [origin] }, + }); + output.push(Object.freeze({ path: `agents/${component.id}.md`, asset })); + origins.push(origin); + } + return Object.freeze({ assets: Object.freeze(output), origins: Object.freeze(origins) }); +} + +/** 创建只通过 Package API 交付 Claude Code Plugin 的 Platform。 */ +export function claudeCode(options: ClaudeCodePlatformOptions = {}): AcpluginPlatform { + validatePlatformOptions(options); + /** strict 由 Core 解释,其余选项进入复制、深冻的 Platform session 数据。 */ + const { strict, ...platformOptions } = options; + return definePlatform({ + id: PLATFORM_ID, + apiVersion: PLATFORM_API_VERSION, + deliveryType: 'plugin', + capabilities: { nodeRuntime: { target: 'node20', format: 'esm', root: 'plugin' } }, + ...(strict === undefined ? {} : { strict }), + options: platformOptions as unknown as JsonObject, + /** 每次 BuildSession 独立捕获 Core 已复制的只读 Platform options。 */ + createSession({ options: sessionOptions }) { + return { + validateComponent: validateClaudeComponent, + /** base Package 同时声明结构化 Document、Component Assets 和完整报告输入。 */ + async createPackage({ project, assets }) { + /** components 是 canonical Resource 到 Claude 原生文件的纯转换结果。 */ + const components = await createClaudeComponents(project, assets); + /** manifest 由 Core codec 负责序列化,Extension 只能填写两个声明点。 */ + const manifest = createPluginDocument({ + metadata: project.metadata, + options: sessionOptions, + components: { + commands: project.commands.length, + skills: project.skills.length, + }, + }); + return { + documents: [manifest.document], + assets: components.assets, + compatibility: components.compatibility, + metadata: manifest.metadata, + }; + }, + /** Platform 解析自己的 opaque union、渲染 assets,并只写自己预留的 manifest 字段。 */ + async finalizePackage({ project, package: mergedPackage, assets, diagnostics }) { + let contributed: Awaited>; + try { + contributed = await contributedAgents(mergedPackage.components, project.agents.map(agent => agent.id), assets); + } catch (error) { + if (!(error instanceof ClaudeComponentContributionError)) + throw error; + diagnostics.report({ + code: error.category === 'collision' + ? 'CLAUDE_COMPONENT_CONTRIBUTION_COLLISION' + : 'CLAUDE_COMPONENT_CONTRIBUTION_INVALID', + severity: 'error', + message: error.message, + }); + return { id: 'plugin', type: 'plugin' as const }; + } + return { + id: 'plugin', + type: 'plugin' as const, + assets: contributed.assets, + ...(project.agents.length + contributed.assets.length === 0 + ? {} + : { + documentFields: [{ + document: 'plugin-manifest', + path: ['agents'], + value: './agents/', + ...(contributed.origins.length === 0 ? {} : { componentOrigins: contributed.origins }), + }], + }), + }; + }, + validatePackage: validateClaudePackage, + /** 可选 Marketplace 只能从已验证 primary 和当前回调新签发 Asset 派生。 */ + async createDistributions(context) { + /** marketplace 必须来自 session 的防御性副本,不能闭包读取作者原对象。 */ + const marketplace = sessionOptions.marketplace as ClaudeCodeMarketplaceOptions | undefined; + if (marketplace === undefined) + return Object.freeze([]); + return Object.freeze([{ + id: 'marketplace', + type: 'marketplace' as const, + assets: await createMarketplaceAssets(context, marketplace), + }]); + }, + }; + }, + }); +} + +export default claudeCode; diff --git a/packages/platforms/claude-code/src/package/components.ts b/packages/platforms/claude-code/src/package/components.ts new file mode 100644 index 0000000..2b1fa02 --- /dev/null +++ b/packages/platforms/claude-code/src/package/components.ts @@ -0,0 +1,264 @@ +import { + markdownWithFrontmatter, + type AgentCapability, + type AssetService, + type CanonicalProject, + type CompatibilityInput, + type PackageAssetInput, + type PlatformComponentValidationContext, +} from '@tokenroll/acplugin/sdk'; +import type { ClaudeNativeAgentComponent } from '../types.js'; + +/** 三类 Component 允许的 Claude Code 专属字段。 */ +const FIELDS = Object.freeze({ + command: new Set(['allowedTools', 'model']), + skill: new Set(['allowedTools', 'model', 'context', 'agent']), + agent: new Set(['tools', 'disallowedTools', 'effort', 'maxTurns', 'skills', 'memory', 'background', 'isolation']), +}); + +/** Claude Code Agent 支持的枚举值集合。 */ +const ENUMS = Object.freeze({ + effort: new Set(['low', 'medium', 'high', 'xhigh', 'max']), + memory: new Set(['user', 'project', 'local']), + isolation: new Set(['worktree']), +}); + +/** 一个 Claude Code base Package 的 Component 转换结果。 */ +export interface ClaudeComponentPackage { + readonly assets: readonly PackageAssetInput[]; + readonly compatibility: readonly CompatibilityInput[]; +} + +/** @returns 值是否为非空字符串。 */ +function nonEmpty(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +/** @returns 值是否为唯一非空字符串数组。 */ +function stringArray(value: unknown): value is readonly string[] { + return Array.isArray(value) && value.every(nonEmpty) && new Set(value).size === value.length; +} + +/** 报告带 canonical Frontmatter 路径的 Claude Code 字段错误。 */ +function fieldError(context: PlatformComponentValidationContext, field: string, message: string): void { + context.diagnostics.report({ + code: 'CLAUDE_COMPONENT_FIELD_INVALID', + severity: 'error', + message, + fieldPath: ['platforms', 'claude-code', field], + }); +} + +/** 校验当前 Component 的 Claude Code namespace,不允许 raw Frontmatter。 */ +export function validateClaudeComponent(context: PlatformComponentValidationContext): void { + /** fields 是 Scanner 已复制冻结的当前 Platform namespace。 */ + const fields = context.component.platforms['claude-code'] ?? {}; + /** allowed 由 canonical Component kind 决定。 */ + const allowed = FIELDS[context.component.kind]; + for (const field of Object.keys(fields)) { + if (!allowed.has(field)) { + context.diagnostics.report({ + code: 'CLAUDE_COMPONENT_FIELD_UNKNOWN', severity: 'error', + message: `Unknown Claude Code ${context.component.kind} field "${field}".`, + fieldPath: ['platforms', 'claude-code', field], + }); + } + } + for (const field of ['allowedTools', 'tools', 'disallowedTools', 'skills']) { + if (fields[field] !== undefined && !stringArray(fields[field])) + fieldError(context, field, `${field} must contain unique non-empty strings.`); + } + for (const field of ['model', 'agent']) { + if (fields[field] !== undefined && !nonEmpty(fields[field])) + fieldError(context, field, `${field} must be a non-empty string.`); + } + if (fields.context !== undefined && fields.context !== 'fork') + fieldError(context, 'context', 'context must be "fork".'); + for (const field of ['effort', 'memory', 'isolation'] as const) { + if (fields[field] !== undefined && !ENUMS[field].has(String(fields[field]))) + fieldError(context, field, `${field} is not supported by Claude Code.`); + } + if (fields.maxTurns !== undefined && (!Number.isInteger(fields.maxTurns) || Number(fields.maxTurns) <= 0)) + fieldError(context, 'maxTurns', 'maxTurns must be a positive integer.'); + if (fields.background !== undefined && typeof fields.background !== 'boolean') + fieldError(context, 'background', 'background must be boolean.'); +} + +/** @returns Agent portable capabilities 的保守 Claude Code tools 映射。 */ +function claudeTools(capabilities: readonly AgentCapability[]): readonly string[] { + /** result 去重多项 capability 指向的相同工具。 */ + const result = new Set(); + /** mapping 固定 portable capability 到 Claude Code 原生工具的最小授权集合。 */ + const mapping: Record = { + 'filesystem:read': ['Read', 'Glob', 'Grep'], + 'filesystem:write': ['Write', 'Edit'], + 'search': ['Glob', 'Grep'], + 'shell': ['Bash'], + 'network': ['WebFetch'], + 'delegate': ['Agent'], + }; + for (const capability of capabilities) { + for (const tool of mapping[capability]) + result.add(tool); + } + if (capabilities.includes('search') && capabilities.includes('network')) + result.add('WebSearch'); + return Object.freeze([...result].sort()); +} + +/** @returns portable model 档位对应的 Claude Code 别名。 */ +function claudeModel(model: 'inherit' | 'fast' | 'capable'): string { + return model === 'fast' ? 'haiku' : model === 'capable' ? 'sonnet' : 'inherit'; +} + +/** @returns 非空工具数组的官方逗号分隔形式。 */ +function toolList(value: unknown): string | undefined { + return Array.isArray(value) && value.length > 0 ? value.join(', ') : undefined; +} + +/** Claude Agent renderer 消费的已验证、Platform-owned frontmatter 输入。 */ +export interface ClaudeAgentDocumentInput { + readonly id: string; + readonly description: string; + readonly body: string; + readonly model: 'inherit' | 'fast' | 'capable'; + readonly tools?: readonly string[]; + readonly disallowedTools?: readonly string[]; + readonly effort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max'; + readonly maxTurns?: number; + readonly skills?: readonly string[]; + readonly memory?: 'user' | 'project' | 'local'; + readonly background?: boolean; + readonly isolation?: 'worktree'; +} + +/** 把一个已验证的 Claude Agent 表示为 Platform 固定 Markdown/frontmatter 字节。 */ +export function renderClaudeAgent(input: ClaudeAgentDocumentInput): string { + return markdownWithFrontmatter({ + name: input.id, + description: input.description, + model: claudeModel(input.model), + tools: toolList(input.tools), + disallowedTools: toolList(input.disallowedTools), + effort: input.effort, + maxTurns: input.maxTurns, + skills: input.skills, + memory: input.memory, + background: input.background, + isolation: input.isolation, + }, input.body); +} + +/** 将 Claude 私有 Component 映射为同一 Agent renderer 的输入。 */ +export function claudeNativeAgentDocument(component: ClaudeNativeAgentComponent): ClaudeAgentDocumentInput { + return Object.freeze({ + id: component.id, + description: component.description, + body: component.body, + model: component.model ?? 'inherit', + ...(component.tools === undefined ? {} : { tools: component.tools }), + ...(component.disallowedTools === undefined ? {} : { disallowedTools: component.disallowedTools }), + ...(component.effort === undefined ? {} : { effort: component.effort }), + ...(component.maxTurns === undefined ? {} : { maxTurns: component.maxTurns }), + ...(component.skills === undefined ? {} : { skills: component.skills }), + ...(component.memory === undefined ? {} : { memory: component.memory }), + ...(component.background === undefined ? {} : { background: component.background }), + ...(component.isolation === undefined ? {} : { isolation: component.isolation }), + }); +} + +/** 把 canonical Components 转为 Claude Code 原生 Asset 与完整兼容性。 */ +export async function createClaudeComponents(project: CanonicalProject, assets: AssetService): Promise { + /** output 只包含 Platform 自有生成 Asset 和已授予的 Skill auxiliary refs。 */ + const output: PackageAssetInput[] = []; + /** compatibility 对每个 canonical Component 精确覆盖 component tuple。 */ + const compatibility: CompatibilityInput[] = []; + for (const command of project.commands) { + /** fields 是已由 validateComponent 校验的平台 namespace。 */ + const fields = command.platforms['claude-code'] ?? {}; + /** asset 是由 Platform owner 签发的 Command Markdown。 */ + const asset = await assets.fromBytes({ + bytes: markdownWithFrontmatter({ + 'description': command.description, + 'argument-hint': command.argumentHint, + 'allowed-tools': fields.allowedTools, + 'model': fields.model, + }, command.body.replaceAll('{{arguments}}', '$ARGUMENTS')), + origin: { operation: 'component-command', subjects: [`command:${command.id}`] }, + }); + output.push(Object.freeze({ path: `commands/${command.id}.md`, asset })); + compatibility.push(Object.freeze({ + subject: `command:${command.id}`, capability: 'component', level: 'native', + reason: 'Claude Code supports native plugin Commands.', + })); + } + for (const skill of project.skills) { + /** fields 是已由 validateComponent 校验的平台 namespace。 */ + const fields = skill.platforms['claude-code'] ?? {}; + /** asset 是由 Platform owner 签发的 Skill 主文档。 */ + const asset = await assets.fromBytes({ + bytes: markdownWithFrontmatter({ + 'name': skill.id, + 'description': skill.description, + 'user-invocable': skill.invocation.user, + 'disable-model-invocation': !skill.invocation.model, + 'allowed-tools': fields.allowedTools, + 'model': fields.model, + 'context': fields.context, + 'agent': fields.agent, + }, skill.body), + origin: { operation: 'component-skill', subjects: [`skill:${skill.id}`] }, + }); + output.push(Object.freeze({ path: `skills/${skill.id}/SKILL.md`, asset })); + for (const auxiliary of skill.auxiliaryFiles) + output.push(Object.freeze({ path: `skills/${skill.id}/${auxiliary.path}`, asset: auxiliary.asset })); + compatibility.push(Object.freeze({ + subject: `skill:${skill.id}`, capability: 'component', level: 'native', + reason: 'Claude Code supports native plugin Skills.', + })); + } + for (const agent of project.agents) { + /** fields 可显式覆盖 portable capability 的保守 tools 映射。 */ + const fields = agent.platforms['claude-code'] ?? {}; + /** tools 优先使用显式平台配置,否则保守映射 portable capabilities。 */ + const tools = stringArray(fields.tools) ? fields.tools : claudeTools(agent.capabilities); + /** validateComponent 已报告非法字段;renderer 只接受经过同一窄化的值。 */ + const disallowedTools = stringArray(fields.disallowedTools) ? fields.disallowedTools : undefined; + const skills = stringArray(fields.skills) ? fields.skills : undefined; + const effort = typeof fields.effort === 'string' && ENUMS.effort.has(fields.effort) + ? fields.effort as ClaudeAgentDocumentInput['effort'] + : undefined; + const maxTurns = typeof fields.maxTurns === 'number' && Number.isInteger(fields.maxTurns) && fields.maxTurns > 0 + ? fields.maxTurns + : undefined; + const memory = typeof fields.memory === 'string' && ENUMS.memory.has(fields.memory) + ? fields.memory as ClaudeAgentDocumentInput['memory'] + : undefined; + const background = typeof fields.background === 'boolean' ? fields.background : undefined; + const isolation = fields.isolation === 'worktree' ? 'worktree' as const : undefined; + /** asset 是由 Platform owner 签发的 Agent Markdown。 */ + const asset = await assets.fromBytes({ + bytes: renderClaudeAgent({ + id: agent.id, + description: agent.description, + body: agent.body, + model: agent.model, + tools, + ...(disallowedTools === undefined ? {} : { disallowedTools }), + ...(effort === undefined ? {} : { effort }), + ...(maxTurns === undefined ? {} : { maxTurns }), + ...(skills === undefined ? {} : { skills }), + ...(memory === undefined ? {} : { memory }), + ...(background === undefined ? {} : { background }), + ...(isolation === undefined ? {} : { isolation }), + }), + origin: { operation: 'component-agent', subjects: [`agent:${agent.id}`] }, + }); + output.push(Object.freeze({ path: `agents/${agent.id}.md`, asset })); + compatibility.push(Object.freeze({ + subject: `agent:${agent.id}`, capability: 'component', level: 'native', + reason: 'Claude Code supports native plugin Agents.', + })); + } + return Object.freeze({ assets: Object.freeze(output), compatibility: Object.freeze(compatibility) }); +} diff --git a/packages/platforms/claude-code/src/package/manifest.ts b/packages/platforms/claude-code/src/package/manifest.ts new file mode 100644 index 0000000..84aeb2c --- /dev/null +++ b/packages/platforms/claude-code/src/package/manifest.ts @@ -0,0 +1,242 @@ +import { + stableJson, + type DistributionAssetInput, + type DistributionContext, + type JsonObject, + type MetadataDispositionInput, + type PackageDocumentInput, + type PluginMetadata, +} from '@tokenroll/acplugin/sdk'; +import type { + ClaudeCodeMarketplaceManifest, + ClaudeCodeMarketplaceOptions, + ClaudeCodeMarketplacePlugin, + ClaudeCodePlatformOptions, + ClaudeCodePluginManifest, +} from '../types.js'; + +/** Claude Code Plugin 清单的稳定逻辑 Document ID。 */ +export const PLUGIN_MANIFEST_ID = 'plugin-manifest'; + +/** Claude Code Plugin 清单相对于安装根的固定路径。 */ +export const PLUGIN_MANIFEST_PATH = '.claude-plugin/plugin.json'; + +/** Claude Code Marketplace 清单相对于 Distribution 根的固定路径。 */ +export const MARKETPLACE_MANIFEST_PATH = '.claude-plugin/marketplace.json'; + +/** Marketplace 名称允许使用的小写 kebab-case 规则。 */ +const MARKETPLACE_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** 校验可选字符串字段,避免空白展示值进入 Marketplace 清单。 */ +function assertOptionalString(value: unknown, field: string): void { + if (value !== undefined && (typeof value !== 'string' || value.trim().length === 0)) + throw new TypeError(`Claude Code ${field} must be a non-empty string.`); +} + +/** 在 Platform 工厂边界校验 Marketplace 选项。 */ +export function validateMarketplaceOptions(marketplace: ClaudeCodeMarketplaceOptions | undefined): void { + if (marketplace === undefined) + return; + /** allowed 是 Marketplace 唯一公开选项集合。 */ + const allowed = new Set(['name', 'owner', 'category', 'tags']); + for (const field of Object.keys(marketplace)) { + if (!allowed.has(field)) + throw new TypeError(`Unknown Claude Code marketplace option "${field}".`); + } + assertOptionalString(marketplace.name, 'marketplace.name'); + if (marketplace.name !== undefined && !MARKETPLACE_NAME_PATTERN.test(marketplace.name)) + throw new TypeError('Claude Code marketplace.name must use lowercase kebab-case.'); + if (marketplace.owner !== undefined) { + /** ownerFields 是 Marketplace owner 唯一支持的身份字段。 */ + const ownerFields = new Set(['name', 'email', 'url']); + for (const field of Object.keys(marketplace.owner)) { + if (!ownerFields.has(field)) + throw new TypeError(`Unknown Claude Code marketplace.owner option "${field}".`); + } + assertOptionalString(marketplace.owner.name, 'marketplace.owner.name'); + assertOptionalString(marketplace.owner.email, 'marketplace.owner.email'); + assertOptionalString(marketplace.owner.url, 'marketplace.owner.url'); + } + assertOptionalString(marketplace.category, 'marketplace.category'); + if (marketplace.tags !== undefined + && (!Array.isArray(marketplace.tags) + || marketplace.tags.some(tag => typeof tag !== 'string' || tag.trim().length === 0) + || new Set(marketplace.tags).size !== marketplace.tags.length)) { + throw new TypeError('Claude Code marketplace.tags must contain unique non-empty strings.'); + } +} + +/** 校验 Claude Code Platform 工厂只接收公开声明的选项。 */ +export function validatePlatformOptions(options: ClaudeCodePlatformOptions): void { + /** allowed 是工厂唯一公开顶层选项集合。 */ + const allowed = new Set(['strict', 'defaultEnabled', 'marketplace']); + for (const field of Object.keys(options)) { + if (!allowed.has(field)) + throw new TypeError(`Unknown Claude Code Platform option "${field}".`); + } + if (options.strict !== undefined && typeof options.strict !== 'boolean') + throw new TypeError('Claude Code strict must be a boolean.'); + if (options.defaultEnabled !== undefined && typeof options.defaultEnabled !== 'boolean') + throw new TypeError('Claude Code defaultEnabled must be a boolean.'); + validateMarketplaceOptions(options.marketplace); +} + +/** @returns 统一元数据和 Component 集合对应的原生 Claude Code 清单。 */ +function pluginManifest( + metadata: PluginMetadata, + options: Readonly, + components: { readonly commands: number; readonly skills: number }, +): ClaudeCodePluginManifest { + return { + name: metadata.name, + version: metadata.version, + description: metadata.description, + ...(metadata.displayName === undefined ? {} : { displayName: metadata.displayName }), + ...(metadata.author === undefined ? {} : { author: metadata.author }), + ...(metadata.homepage === undefined ? {} : { homepage: metadata.homepage }), + ...(metadata.repository === undefined ? {} : { repository: metadata.repository }), + ...(metadata.license === undefined ? {} : { license: metadata.license }), + ...(metadata.keywords.length === 0 ? {} : { keywords: metadata.keywords }), + ...(options.defaultEnabled === undefined ? {} : { defaultEnabled: options.defaultEnabled as boolean }), + ...(components.commands === 0 ? {} : { commands: './commands/' }), + ...(components.skills === 0 ? {} : { skills: './skills/' }), + }; +} + +/** @returns 当前工程全部实际元数据字段的完整 emitted disposition。 */ +function metadataDispositions(metadata: PluginMetadata): readonly MetadataDispositionInput[] { + /** fields 与 Core metadata coverage 使用相同的规范字段粒度。 */ + const fields = ['name', 'version', 'description']; + for (const field of ['displayName', 'homepage', 'repository', 'license'] as const) { + if (metadata[field] !== undefined) + fields.push(field); + } + if (metadata.author !== undefined) { + fields.push('author.name'); + if (metadata.author.email !== undefined) + fields.push('author.email'); + if (metadata.author.url !== undefined) + fields.push('author.url'); + } + if (metadata.keywords.length > 0) + fields.push('keywords'); + return Object.freeze(fields.map(field => Object.freeze({ + field, + disposition: 'emitted' as const, + output: `${PLUGIN_MANIFEST_PATH}/${field}`, + reason: `Claude Code plugin.json supports ${field}.`, + }))); +} + +/** 创建由 Core codec 序列化、只开放 Hooks/MCP 字段的 Plugin Document。 */ +export function createPluginDocument(input: { + readonly metadata: PluginMetadata; + readonly options: Readonly; + readonly components: { readonly commands: number; readonly skills: number }; +}): { readonly document: PackageDocumentInput; readonly metadata: readonly MetadataDispositionInput[] } { + /** document 是 Platform 唯一拥有的结构化主清单。 */ + const document: PackageDocumentInput = Object.freeze({ + id: PLUGIN_MANIFEST_ID, + path: PLUGIN_MANIFEST_PATH, + format: 'json', + value: pluginManifest(input.metadata, input.options, input.components) as unknown as JsonObject, + extensionPoints: Object.freeze([ + Object.freeze(['hooks'] as const), + Object.freeze(['mcpServers'] as const), + ]), + /** 合并私有 Component 后才由 Claude Platform 自己决定是否注册 agents 目录。 */ + finalizationPoints: Object.freeze([Object.freeze(['agents'] as const)]), + }); + return Object.freeze({ document, metadata: metadataDispositions(input.metadata) }); +} + +/** @returns 统一元数据和显式选项推导出的 Marketplace owner。 */ +function marketplaceOwner( + metadata: PluginMetadata, + options: ClaudeCodeMarketplaceOptions, +): ClaudeCodeMarketplaceManifest['owner'] { + if (options.owner !== undefined) + return options.owner; + if (metadata.author !== undefined) { + return { + name: metadata.author.name, + ...(metadata.author.email === undefined ? {} : { email: metadata.author.email }), + ...(metadata.author.url === undefined ? {} : { url: metadata.author.url }), + }; + } + return { name: metadata.name }; +} + +/** @returns 已验证主 Plugin 清单对应的 Marketplace 安装条目。 */ +function marketplacePlugin( + manifest: ClaudeCodePluginManifest, + options: ClaudeCodeMarketplaceOptions, +): ClaudeCodeMarketplacePlugin { + return { + name: manifest.name, + source: './', + description: manifest.description, + version: manifest.version, + ...(manifest.author === undefined ? {} : { author: manifest.author }), + ...(manifest.homepage === undefined ? {} : { homepage: manifest.homepage }), + ...(manifest.repository === undefined ? {} : { repository: manifest.repository }), + ...(manifest.license === undefined ? {} : { license: manifest.license }), + ...(manifest.keywords === undefined ? {} : { keywords: manifest.keywords }), + ...(options.category === undefined ? {} : { category: options.category }), + ...(options.tags === undefined ? {} : { tags: options.tags }), + strict: true, + }; +} + +/** 从 validated primary 的真实 AssetRef 读取 Plugin 清单。 */ +async function readPrimaryManifest(context: DistributionContext): Promise { + /** manifestAsset 必须来自当前 primary 的固定 Document 输出。 */ + const manifestAsset = context.primary.assets.find(asset => asset.path === PLUGIN_MANIFEST_PATH); + if (manifestAsset === undefined) + throw new Error(`Claude Code primary Package is missing ${PLUGIN_MANIFEST_PATH}.`); + /** value 在读取必填字段前保持未知。 */ + const value: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode( + await context.assets.read(manifestAsset.asset), + )); + if (value === null || typeof value !== 'object' || Array.isArray(value)) + throw new Error('Claude Code primary Package has an invalid Plugin Manifest.'); + /** manifest 只在基础身份通过后用于生成 Marketplace 条目。 */ + const manifest = value as Record; + if (typeof manifest.name !== 'string' || typeof manifest.version !== 'string' || typeof manifest.description !== 'string') + throw new Error('Claude Code primary Package has incomplete Plugin metadata.'); + return manifest as unknown as ClaudeCodePluginManifest; +} + +/** 从 validated primary 创建保留全部 AssetRef 身份的自包含 Marketplace。 */ +export async function createMarketplaceAssets( + context: DistributionContext, + options: ClaudeCodeMarketplaceOptions, +): Promise { + if (context.primary.assets.some(asset => asset.path === MARKETPLACE_MANIFEST_PATH)) { + context.diagnostics.report({ + code: 'CLAUDE_MARKETPLACE_PATH_CONFLICT', severity: 'error', + message: 'The primary Plugin already contains the reserved Marketplace manifest path.', + }); + return Object.freeze([]); + } + /** manifest 是已经过主 Package validator 的真实清单。 */ + const manifest = await readPrimaryManifest(context); + /** marketplace 只引用当前单一 primary,避免建立第二套多 Package 编排语义。 */ + const marketplace: ClaudeCodeMarketplaceManifest = { + name: options.name ?? `${context.project.metadata.name}-marketplace`, + owner: marketplaceOwner(context.project.metadata, options), + description: context.project.metadata.description, + version: context.project.metadata.version, + metadata: { pluginRoot: './' }, + plugins: [marketplacePlugin(manifest, options)], + }; + /** marketplaceAsset 是 Distribution callback 本次唯一新签发的 Asset。 */ + const marketplaceAsset = await context.assets.fromBytes({ + bytes: stableJson(marketplace as unknown as JsonObject), + origin: { operation: 'marketplace-manifest', subjects: ['distribution:marketplace'] }, + }); + return Object.freeze([ + ...context.primary.assets.map(asset => Object.freeze({ path: asset.path, asset: asset.asset })), + Object.freeze({ path: MARKETPLACE_MANIFEST_PATH, asset: marketplaceAsset }), + ]); +} diff --git a/packages/platforms/claude-code/src/package/validation/hooks.ts b/packages/platforms/claude-code/src/package/validation/hooks.ts new file mode 100644 index 0000000..f03ff3a --- /dev/null +++ b/packages/platforms/claude-code/src/package/validation/hooks.ts @@ -0,0 +1,315 @@ +/** Claude Code Hook wire contract validator。 */ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { JsonValue } from '@tokenroll/acplugin/sdk'; +import { + isRecord, + report, + type JsonRecord, + type PlatformValidateContext, +} from './shared.js'; + +/** Claude Code 当前公开的全部 Hook 事件,包括可移植事件和平台专属事件。 */ +const HOOK_EVENTS = new Set([ + 'SessionStart', 'SessionEnd', 'UserPromptSubmit', 'PreToolUse', 'PermissionRequest', 'PostToolUse', + 'PreCompact', 'PostCompact', 'SubagentStart', 'SubagentStop', 'Stop', 'Setup', 'UserPromptExpansion', + 'PermissionDenied', 'PostToolUseFailure', 'PostToolBatch', 'Notification', 'MessageDisplay', 'TaskCreated', + 'TaskCompleted', 'StopFailure', 'TeammateIdle', 'InstructionsLoaded', 'ConfigChange', 'CwdChanged', + 'DirectoryAdded', 'FileChanged', 'WorktreeCreate', 'WorktreeRemove', 'Elicitation', 'ElicitationResult', +]); + +/** Claude Code Hook 配置文件顶层允许出现的字段。 */ +const HOOK_CONFIG_FIELDS = new Set(['description', 'hooks']); + +/** 单个 Claude Code Hook matcher 分组允许出现的字段。 */ +const HOOK_GROUP_FIELDS = new Set(['matcher', 'hooks']); + +/** Claude Code 当前公开的 Handler 类型。 */ +const HOOK_HANDLER_TYPES = new Set(['command', 'prompt', 'agent', 'http', 'mcp_tool']); + +/** Claude Code 明确允许五类 Handler 的事件。 */ +const HOOK_EVENTS_WITH_ALL_HANDLER_TYPES = new Set([ + 'PermissionDenied', 'PermissionRequest', 'PostToolBatch', 'PostToolUse', 'PostToolUseFailure', + 'PreToolUse', 'Stop', 'SubagentStop', 'TaskCompleted', 'TaskCreated', 'TeammateIdle', + 'UserPromptExpansion', 'UserPromptSubmit', +]); + +/** Claude Code 允许 command/http/mcp_tool、但不允许 prompt/agent 的事件。 */ +const HOOK_EVENTS_WITH_COMMAND_HTTP_MCP_TYPES = new Set([ + 'ConfigChange', 'CwdChanged', 'DirectoryAdded', 'Elicitation', 'ElicitationResult', 'FileChanged', + 'InstructionsLoaded', 'Notification', 'PostCompact', 'PreCompact', 'SessionEnd', 'StopFailure', + 'SubagentStart', 'WorktreeCreate', 'WorktreeRemove', +]); + +/** Claude Code 只允许 command 和 mcp_tool 的启动类事件。 */ +const HOOK_EVENTS_WITH_COMMAND_MCP_TYPES = new Set(['SessionStart', 'Setup']); + +/** 非 prompt/agent 事件共同使用的三类 Handler。 */ +const HOOK_HANDLER_COMMAND_HTTP_MCP_TYPES = new Set(['command', 'http', 'mcp_tool']); + +/** 启动类事件共同使用的两类 Handler。 */ +const HOOK_HANDLER_COMMAND_MCP_TYPES = new Set(['command', 'mcp_tool']); + +/** 未出现在官方类型矩阵中的事件使用最保守 command 契约。 */ +const HOOK_HANDLER_COMMAND_ONLY = new Set(['command']); + +/** 所有 Claude Code Handler 类型共同允许出现的执行字段。 */ +const HOOK_HANDLER_COMMON_FIELDS = ['type', 'if', 'timeout', 'statusMessage', 'once'] as const; + +/** 不同 Claude Code Handler 类型允许出现的字段。 */ +const HOOK_HANDLER_FIELDS: Readonly>> = Object.freeze({ + command: new Set([...HOOK_HANDLER_COMMON_FIELDS, 'command', 'args', 'async', 'asyncRewake', 'shell']), + prompt: new Set([...HOOK_HANDLER_COMMON_FIELDS, 'prompt', 'model']), + agent: new Set([...HOOK_HANDLER_COMMON_FIELDS, 'prompt', 'model']), + http: new Set([...HOOK_HANDLER_COMMON_FIELDS, 'url', 'headers', 'allowedEnvVars']), + mcp_tool: new Set([...HOOK_HANDLER_COMMON_FIELDS, 'server', 'tool', 'input']), +}); + +/** + * 校验 Claude Code Hook matcher 是可执行的正则字符串。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param value matcher 候选值。 + * @param fieldPath matcher 在最终 Hook 配置中的字段路径。 + */ +function validateHookMatcher( + context: PlatformValidateContext, + value: JsonValue, + fieldPath: readonly (string | number)[], +): void { + if (typeof value !== 'string') { + report(context, 'CLAUDE_HOOK_MATCHER_INVALID', 'Hook matcher must be a regular-expression string.', fieldPath); + return; + } + try { + /** 构造正则只用于验证平台将要解析的表达式语法。 */ + const expression = new RegExp(value); + void expression; + } catch { + report(context, 'CLAUDE_HOOK_MATCHER_INVALID', 'Hook matcher must be a valid regular expression.', fieldPath); + } +} + +/** + * 校验 Claude Code Hook Handler 的类型、必填字段和公共执行选项。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param event 当前 Handler 所属事件。 + * @param value Handler 候选值。 + * @param fieldPath Handler 在最终 Hook 配置中的字段路径。 + */ +function validateHookHandler( + context: PlatformValidateContext, + event: string, + value: JsonValue, + fieldPath: readonly (string | number)[], +): void { + if (!isRecord(value)) { + report(context, 'CLAUDE_HOOK_HANDLER_INVALID', 'Hook handlers must be JSON objects.', fieldPath); + return; + } + /** 已完成对象检查的 Handler 类型候选。 */ + const type = value.type; + if (typeof type !== 'string' || !HOOK_HANDLER_TYPES.has(type)) { + report(context, 'CLAUDE_HOOK_HANDLER_TYPE_INVALID', 'Hook handler type is not supported by Claude Code.', [...fieldPath, 'type']); + return; + } + /** 当前事件由官方矩阵允许的 Handler 类型集合;其余事件只接受 command。 */ + const allowedTypes = HOOK_EVENTS_WITH_ALL_HANDLER_TYPES.has(event) + ? HOOK_HANDLER_TYPES + : HOOK_EVENTS_WITH_COMMAND_HTTP_MCP_TYPES.has(event) + ? HOOK_HANDLER_COMMAND_HTTP_MCP_TYPES + : HOOK_EVENTS_WITH_COMMAND_MCP_TYPES.has(event) + ? HOOK_HANDLER_COMMAND_MCP_TYPES + : HOOK_HANDLER_COMMAND_ONLY; + if (!allowedTypes.has(type)) { + report( + context, + 'CLAUDE_HOOK_HANDLER_EVENT_UNSUPPORTED', + `${type} Hook handlers are not supported for Claude Code event "${event}".`, + [...fieldPath, 'type'], + ); + } + /** 当前 Handler 类型对应的官方字段集合。 */ + const fields = HOOK_HANDLER_FIELDS[type]!; + for (const field of Object.keys(value)) { + if (!fields.has(field)) + report(context, 'CLAUDE_HOOK_HANDLER_FIELD_UNKNOWN', `Unknown Claude Code ${type} Hook field "${field}".`, [...fieldPath, field]); + } + /** 当前 Handler 类型要求提供的全部非空字符串目标字段。 */ + const requiredFields = type === 'command' + ? ['command'] + : type === 'http' + ? ['url'] + : type === 'mcp_tool' + ? ['server', 'tool'] + : ['prompt']; + /** requiredField 表示当前类型的一个必填目标字段。 */ + for (const requiredField of requiredFields) { + if (typeof value[requiredField] !== 'string' || value[requiredField].trim().length === 0) { + report(context, 'CLAUDE_HOOK_HANDLER_TARGET_INVALID', `${type} Hook ${requiredField} must be a non-empty string.`, [...fieldPath, requiredField]); + } + } + if (value.args !== undefined + && (!Array.isArray(value.args) || value.args.some(argument => typeof argument !== 'string'))) { + report(context, 'CLAUDE_HOOK_HANDLER_ARGS_INVALID', 'command Hook args must contain only strings.', [...fieldPath, 'args']); + } + if (value.timeout !== undefined + && (typeof value.timeout !== 'number' || !Number.isFinite(value.timeout) || value.timeout <= 0)) { + report(context, 'CLAUDE_HOOK_TIMEOUT_INVALID', 'Hook timeout must be a positive finite number of seconds.', [...fieldPath, 'timeout']); + } else if (event === 'SessionEnd' && typeof value.timeout === 'number' && value.timeout > 60) { + report(context, 'CLAUDE_HOOK_TIMEOUT_LIMIT', 'SessionEnd Hook timeout must not exceed 60 seconds.', [...fieldPath, 'timeout']); + } + if (value.statusMessage !== undefined + && (typeof value.statusMessage !== 'string' || value.statusMessage.trim().length === 0)) { + report(context, 'CLAUDE_HOOK_STATUS_INVALID', 'Hook statusMessage must be a non-empty string.', [...fieldPath, 'statusMessage']); + } + if (value.if !== undefined && (typeof value.if !== 'string' || value.if.trim().length === 0)) + report(context, 'CLAUDE_HOOK_IF_INVALID', 'Hook if must be a non-empty permission rule.', [...fieldPath, 'if']); + if (value.async !== undefined && typeof value.async !== 'boolean') + report(context, 'CLAUDE_HOOK_ASYNC_INVALID', 'command Hook async must be a boolean.', [...fieldPath, 'async']); + if (value.asyncRewake !== undefined && typeof value.asyncRewake !== 'boolean') + report(context, 'CLAUDE_HOOK_ASYNC_REWAKE_INVALID', 'command Hook asyncRewake must be a boolean.', [...fieldPath, 'asyncRewake']); + if (value.once !== undefined && typeof value.once !== 'boolean') + report(context, 'CLAUDE_HOOK_ONCE_INVALID', 'Hook once must be a boolean.', [...fieldPath, 'once']); + if (value.shell !== undefined && value.shell !== 'bash' && value.shell !== 'powershell') + report(context, 'CLAUDE_HOOK_SHELL_INVALID', 'command Hook shell must be bash or powershell.', [...fieldPath, 'shell']); + if (value.model !== undefined && (typeof value.model !== 'string' || value.model.trim().length === 0)) + report(context, 'CLAUDE_HOOK_MODEL_INVALID', 'prompt or agent Hook model must be a non-empty string.', [...fieldPath, 'model']); + if (value.url !== undefined && typeof value.url === 'string') { + try { + /** HTTP Hook 地址允许官方支持的 HTTP(S),但拒绝内联凭据。 */ + const url = new URL(value.url); + if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username !== '' || url.password !== '') + throw new TypeError('Unsafe HTTP Hook URL.'); + } catch { + report(context, 'CLAUDE_HOOK_URL_INVALID', 'HTTP Hook url must be an HTTP(S) URL without credentials.', [...fieldPath, 'url']); + } + } + if (value.headers !== undefined + && (!isRecord(value.headers) || Object.values(value.headers).some(header => typeof header !== 'string'))) { + report(context, 'CLAUDE_HOOK_HEADERS_INVALID', 'HTTP Hook headers must map names to string values.', [...fieldPath, 'headers']); + } + if (value.allowedEnvVars !== undefined + && (!Array.isArray(value.allowedEnvVars) + || value.allowedEnvVars.some(variable => typeof variable !== 'string' || variable.trim().length === 0))) { + report(context, 'CLAUDE_HOOK_ENV_INVALID', 'HTTP Hook allowedEnvVars must contain non-empty strings.', [...fieldPath, 'allowedEnvVars']); + } + if (value.input !== undefined && !isRecord(value.input)) + report(context, 'CLAUDE_HOOK_MCP_INPUT_INVALID', 'mcp_tool Hook input must be a JSON object.', [...fieldPath, 'input']); +} + +/** + * 校验 Claude Code Hook 事件映射及其 matcher 分组。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param value `hooks` 字段中的事件映射候选。 + * @param fieldPath 事件映射在最终配置中的字段路径。 + */ +function validateHookEvents( + context: PlatformValidateContext, + value: JsonValue, + fieldPath: readonly (string | number)[], +): void { + if (!isRecord(value)) { + report(context, 'CLAUDE_HOOK_EVENTS_INVALID', 'hooks must contain an event mapping.', fieldPath); + return; + } + /** [event, groups] 表示当前遍历的原生事件和 matcher 分组。 */ + for (const [event, groups] of Object.entries(value)) { + /** 当前事件在最终配置中的稳定字段路径。 */ + const eventPath = [...fieldPath, event]; + if (!HOOK_EVENTS.has(event)) { + report(context, 'CLAUDE_HOOK_EVENT_UNKNOWN', `Unknown Claude Code Hook event "${event}".`, eventPath); + continue; + } + if (!Array.isArray(groups) || groups.length === 0) { + report(context, 'CLAUDE_HOOK_GROUPS_INVALID', 'Each Hook event must contain one or more matcher groups.', eventPath); + continue; + } + /** [groupIndex, groupValue] 表示当前事件中的 matcher 分组。 */ + for (const [groupIndex, groupValue] of groups.entries()) { + /** 当前 matcher 分组的稳定字段路径。 */ + const groupPath = [...eventPath, groupIndex]; + if (!isRecord(groupValue)) { + report(context, 'CLAUDE_HOOK_GROUP_INVALID', 'Hook matcher groups must be JSON objects.', groupPath); + continue; + } + for (const field of Object.keys(groupValue)) { + if (!HOOK_GROUP_FIELDS.has(field)) + report(context, 'CLAUDE_HOOK_GROUP_FIELD_UNKNOWN', `Unknown Claude Code Hook group field "${field}".`, [...groupPath, field]); + } + if (groupValue.matcher !== undefined) + validateHookMatcher(context, groupValue.matcher, [...groupPath, 'matcher']); + if (!Array.isArray(groupValue.hooks) || groupValue.hooks.length === 0) { + report(context, 'CLAUDE_HOOK_HANDLERS_INVALID', 'Hook matcher groups must contain one or more handlers.', [...groupPath, 'hooks']); + continue; + } + /** [handlerIndex, handler] 表示当前 matcher 分组中的 Handler。 */ + for (const [handlerIndex, handler] of groupValue.hooks.entries()) + validateHookHandler(context, event, handler, [...groupPath, 'hooks', handlerIndex]); + } + } +} + +/** + * 校验 Claude Code `hooks.json` 顶层结构。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param value 已解析的 Hook 配置对象。 + * @param fieldPath 配置在 Plugin Manifest 中的字段路径。 + * @param wrapped 是否要求配置使用 `hooks.json` 顶层包装。 + */ +export function validateHookConfig( + context: PlatformValidateContext, + value: JsonRecord, + fieldPath: readonly (string | number)[], + wrapped: boolean, +): void { + if (!wrapped && value.hooks === undefined && value.description === undefined) { + validateHookEvents(context, value, fieldPath); + return; + } + for (const field of Object.keys(value)) { + if (!HOOK_CONFIG_FIELDS.has(field)) + report(context, 'CLAUDE_HOOK_CONFIG_FIELD_UNKNOWN', `Unknown Claude Code Hook config field "${field}".`, [...fieldPath, field]); + } + if (value.description !== undefined + && (typeof value.description !== 'string' || value.description.trim().length === 0)) { + report(context, 'CLAUDE_HOOK_DESCRIPTION_INVALID', 'Hook config description must be a non-empty string.', [...fieldPath, 'description']); + } + if (value.hooks === undefined) { + report(context, 'CLAUDE_HOOKS_REQUIRED', 'Hook config must contain a hooks event mapping.', [...fieldPath, 'hooks']); + return; + } + validateHookEvents(context, value.hooks, [...fieldPath, 'hooks']); +} + +/** + * 读取并校验 Plugin 根内被引用的 Claude Code `hooks.json`。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 + * @param reference 已通过安装根路径规则的 Hook 配置引用。 + * @param fieldPath 引用在 Plugin Manifest 中的字段路径。 + */ +export async function validateHookFile( + context: PlatformValidateContext, + pluginRoot: string, + reference: string, + fieldPath: readonly (string | number)[], +): Promise { + try { + /** Hook 配置引用相对于当前 Plugin 根解析后的绝对候选路径。 */ + const hookPath = path.join(context.candidate.root, pluginRoot, reference.slice(2)); + /** JSON.parse 返回的未知配置值。 */ + const value: unknown = JSON.parse(await fs.readFile(hookPath, 'utf8')); + if (!isRecord(value)) { + report(context, 'CLAUDE_HOOK_CONFIG_OBJECT_REQUIRED', 'Hook config must contain a JSON object.', fieldPath); + return; + } + validateHookConfig(context, value, fieldPath, true); + } catch { + report(context, 'CLAUDE_HOOK_CONFIG_READ_FAILED', 'Hook config reference must contain valid JSON.', fieldPath); + } +} diff --git a/packages/platforms/claude-code/src/package/validation/index.ts b/packages/platforms/claude-code/src/package/validation/index.ts new file mode 100644 index 0000000..5849801 --- /dev/null +++ b/packages/platforms/claude-code/src/package/validation/index.ts @@ -0,0 +1,20 @@ +/** Claude Code 主 Plugin 或 Marketplace Distribution 的 validator 组合入口。 */ +import { MARKETPLACE_MANIFEST_PATH, PLUGIN_MANIFEST_PATH } from '../manifest.js'; +import { validatePluginManifest } from './manifest.js'; +import { validateMarketplace } from './marketplace.js'; +import { readJson, type PlatformValidateContext } from './shared.js'; + +/** 验证 Claude Code 最终安装候选。 */ +export async function validateClaudePackage(context: PlatformValidateContext): Promise { + if (context.candidate.unit.role !== 'distribution') { + /** 主单元始终使用安装根固定 Plugin Manifest。 */ + const plugin = await readJson(context, PLUGIN_MANIFEST_PATH); + if (plugin !== undefined) + await validatePluginManifest(context, plugin); + return; + } + /** Marketplace Distribution 额外需要的根清单。 */ + const marketplace = await readJson(context, MARKETPLACE_MANIFEST_PATH); + if (marketplace !== undefined) + await validateMarketplace(context, marketplace); +} diff --git a/packages/platforms/claude-code/src/package/validation/manifest.ts b/packages/platforms/claude-code/src/package/validation/manifest.ts new file mode 100644 index 0000000..7f53fc9 --- /dev/null +++ b/packages/platforms/claude-code/src/package/validation/manifest.ts @@ -0,0 +1,148 @@ +/** Claude Code Plugin Manifest 与安装根引用 validator。 */ +import type { JsonValue } from '@tokenroll/acplugin/sdk'; +import { validateHookConfig, validateHookFile } from './hooks.js'; +import { validateMcpFile, validateMcpServers } from './mcp.js'; +import { + isRecord, + isSafePluginReference, + referenceExists, + report, + scopedAssets, + type JsonRecord, + type PlatformValidateContext, +} from './shared.js'; + +/** Claude Code Plugin 清单允许出现的官方根字段。 */ +const PLUGIN_FIELDS = new Set([ + '$schema', 'name', 'version', 'description', 'displayName', 'author', 'homepage', 'repository', 'license', + 'keywords', 'metadata', 'defaultEnabled', 'commands', 'agents', 'skills', 'hooks', 'mcpServers', 'lspServers', + 'outputStyles', 'experimental', 'dependencies', +]); + +/** 由当前 Platform 生成并需要执行安装根引用验证的 Component 字段。 */ +const COMPONENT_REFERENCE_FIELDS = ['commands', 'skills', 'agents'] as const; + +/** 允许按路径或内联对象表达的 Extension 字段。 */ +const EXTENSION_REFERENCE_FIELDS = ['hooks', 'mcpServers'] as const; + +/** Claude Code Plugin 名称允许使用的小写 kebab-case 规则。 */ +const PLUGIN_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** + * 校验一个清单引用值的类型、安全性和安装根内存在性。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param assets 当前 Package 的 Asset 路径集合。 + * @param field 当前引用所属的清单字段。 + * @param value 单路径或路径数组候选。 + */ +function validateReferences( + context: PlatformValidateContext, + assets: ReadonlySet, + field: string, + value: JsonValue, +): void { + /** 统一转换后的引用列表,保持清单声明顺序。 */ + const references = typeof value === 'string' + ? [value] + : Array.isArray(value) && value.every(item => typeof item === 'string') + ? value as readonly string[] + : undefined; + if (references === undefined || references.length === 0) { + report(context, 'CLAUDE_MANIFEST_REFERENCE_INVALID', `${field} must be a path or non-empty path array.`, [field]); + return; + } + for (const [index, reference] of references.entries()) { + /** 当前引用在单值或数组字段中的诊断位置。 */ + const fieldPath: readonly (string | number)[] = references.length === 1 ? [field] : [field, index]; + if (!isSafePluginReference(reference)) { + report(context, 'CLAUDE_MANIFEST_REFERENCE_UNSAFE', `${field} references must start with ./ and stay inside the Plugin root.`, fieldPath); + } else if (!referenceExists(assets, reference)) { + report(context, 'CLAUDE_MANIFEST_REFERENCE_MISSING', `${field} references a missing Plugin file or directory.`, fieldPath); + } + } +} + +/** + * 校验 Claude Code Plugin 清单字段、Component 目录和 Extension 引用。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param manifest 已解析的 Plugin 清单对象。 + * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 + */ +export async function validatePluginManifest( + context: PlatformValidateContext, + manifest: JsonRecord, + pluginRoot = '', +): Promise { + /** 当前 Plugin 安装根内的相对 Asset 路径集合。 */ + const assets = scopedAssets(context, pluginRoot); + for (const field of Object.keys(manifest)) { + if (!PLUGIN_FIELDS.has(field)) + report(context, 'CLAUDE_MANIFEST_FIELD_UNKNOWN', `Unknown Claude Code Plugin field "${field}".`, [field]); + } + /** 必填字符串字段及其期望的非空值。 */ + const required = ['name', 'version', 'description'] as const; + for (const field of required) { + if (typeof manifest[field] !== 'string' || manifest[field].trim().length === 0) + report(context, 'CLAUDE_MANIFEST_FIELD_REQUIRED', `${field} must be a non-empty string.`, [field]); + } + if (typeof manifest.name === 'string' && !PLUGIN_NAME_PATTERN.test(manifest.name)) + report(context, 'CLAUDE_MANIFEST_NAME_INVALID', 'name must use lowercase kebab-case.', ['name']); + /** 可选字符串元数据必须保持非空字符串形态。 */ + const optionalStrings = ['displayName', 'homepage', 'repository', 'license'] as const; + for (const field of optionalStrings) { + if (manifest[field] !== undefined && (typeof manifest[field] !== 'string' || manifest[field].trim().length === 0)) + report(context, 'CLAUDE_MANIFEST_METADATA_INVALID', `${field} must be a non-empty string.`, [field]); + } + if (manifest.author !== undefined) { + /** Plugin 清单中经过对象形态检查的作者字段。 */ + const author = isRecord(manifest.author) ? manifest.author : undefined; + if (author === undefined || typeof author.name !== 'string' || author.name.trim().length === 0) { + report(context, 'CLAUDE_MANIFEST_AUTHOR_INVALID', 'author.name must be a non-empty string.', ['author', 'name']); + } else { + /** author 的可选联系字段只能是非空字符串。 */ + const authorFields = ['email', 'url'] as const; + for (const field of authorFields) { + if (author[field] !== undefined && (typeof author[field] !== 'string' || author[field].trim().length === 0)) + report(context, 'CLAUDE_MANIFEST_AUTHOR_INVALID', `author.${field} must be a non-empty string.`, ['author', field]); + } + } + } + if (manifest.keywords !== undefined + && (!Array.isArray(manifest.keywords) + || manifest.keywords.some(keyword => typeof keyword !== 'string' || keyword.trim().length === 0) + || new Set(manifest.keywords).size !== manifest.keywords.length)) { + report(context, 'CLAUDE_MANIFEST_KEYWORDS_INVALID', 'keywords must contain unique non-empty strings.', ['keywords']); + } + if (manifest.defaultEnabled !== undefined && typeof manifest.defaultEnabled !== 'boolean') + report(context, 'CLAUDE_MANIFEST_DEFAULT_INVALID', 'defaultEnabled must be a boolean.', ['defaultEnabled']); + for (const field of COMPONENT_REFERENCE_FIELDS) { + if (manifest[field] !== undefined) + validateReferences(context, assets, field, manifest[field]); + } + for (const field of EXTENSION_REFERENCE_FIELDS) { + /** 当前 Extension 添加的清单字段值。 */ + const value = manifest[field]; + if (value === undefined) + continue; + if (typeof value === 'string') { + validateReferences(context, assets, field, value); + if (field === 'hooks' && isSafePluginReference(value) && referenceExists(assets, value)) + await validateHookFile(context, pluginRoot, value, [field]); + if (field === 'mcpServers' && isSafePluginReference(value) && referenceExists(assets, value)) + await validateMcpFile(context, pluginRoot, value, [field]); + } else if (!isRecord(value)) { + report(context, 'CLAUDE_EXTENSION_FIELD_INVALID', `${field} must be a Plugin path or inline object.`, [field]); + } else if (field === 'hooks') { + validateHookConfig(context, value, [field], false); + } else { + validateMcpServers(context, value, [field]); + } + } + if (manifest.hooks === undefined && assets.has('hooks/hooks.json')) + await validateHookFile(context, pluginRoot, './hooks/hooks.json', ['hooks']); + /** Claude Code 会自动发现 Plugin 根 `.mcp.json`,即使 Manifest 未显式引用。 */ + if (manifest.mcpServers === undefined && assets.has('.mcp.json')) + await validateMcpFile(context, pluginRoot, './.mcp.json', ['mcpServers']); +} diff --git a/packages/platforms/claude-code/src/package/validation/marketplace.ts b/packages/platforms/claude-code/src/package/validation/marketplace.ts new file mode 100644 index 0000000..2497054 --- /dev/null +++ b/packages/platforms/claude-code/src/package/validation/marketplace.ts @@ -0,0 +1,113 @@ +/** Claude Code Marketplace Distribution validator。 */ +import { PLUGIN_MANIFEST_PATH } from '../manifest.js'; +import { validatePluginManifest } from './manifest.js'; +import { + isRecord, + readJson, + report, + type JsonRecord, + type PlatformValidateContext, +} from './shared.js'; + +/** 多 Plugin Marketplace 的本地来源必须使用稳定单元目录。 */ +const MARKETPLACE_PLUGIN_SOURCE_PATTERN = /^\.\/plugins\/[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** Claude Code Marketplace 根清单允许出现的官方字段。 */ +const MARKETPLACE_FIELDS = new Set(['name', 'owner', 'description', 'version', 'metadata', 'plugins']); + +/** Claude Code Marketplace 每个 Plugin 条目允许出现的官方字段。 */ +const MARKETPLACE_PLUGIN_FIELDS = new Set([ + 'name', 'source', 'description', 'version', 'author', 'homepage', 'repository', 'license', 'keywords', + 'category', 'tags', 'strict', +]); + +/** + * 校验 Marketplace 根清单与自包含 Plugin 的身份和引用。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param marketplace 已解析的 Marketplace 清单。 + */ +export async function validateMarketplace( + context: PlatformValidateContext, + marketplace: JsonRecord, +): Promise { + for (const field of Object.keys(marketplace)) { + if (!MARKETPLACE_FIELDS.has(field)) + report(context, 'CLAUDE_MARKETPLACE_FIELD_UNKNOWN', `Unknown Claude Code Marketplace field "${field}".`, [field]); + } + if (typeof marketplace.name !== 'string' || marketplace.name.trim().length === 0) + report(context, 'CLAUDE_MARKETPLACE_NAME_REQUIRED', 'Marketplace name must be a non-empty string.', ['name']); + if (!isRecord(marketplace.owner) || typeof marketplace.owner.name !== 'string' || marketplace.owner.name.trim().length === 0) { + report(context, 'CLAUDE_MARKETPLACE_OWNER_REQUIRED', 'Marketplace owner.name must be present.', ['owner', 'name']); + } else { + /** Marketplace owner 可选联系方式字段。 */ + const ownerFields = ['email', 'url'] as const; + for (const field of ownerFields) { + if (marketplace.owner[field] !== undefined + && (typeof marketplace.owner[field] !== 'string' || marketplace.owner[field].trim().length === 0)) { + report(context, 'CLAUDE_MARKETPLACE_OWNER_INVALID', `Marketplace owner.${field} must be a non-empty string.`, ['owner', field]); + } + } + } + if (typeof marketplace.description !== 'string' || marketplace.description.trim().length === 0) + report(context, 'CLAUDE_MARKETPLACE_DESCRIPTION_REQUIRED', 'Marketplace description must be a non-empty string.', ['description']); + if (typeof marketplace.version !== 'string' || marketplace.version.trim().length === 0) + report(context, 'CLAUDE_MARKETPLACE_VERSION_REQUIRED', 'Marketplace version must be a non-empty string.', ['version']); + if (!isRecord(marketplace.metadata) || marketplace.metadata.pluginRoot !== './') + report(context, 'CLAUDE_MARKETPLACE_ROOT_INVALID', 'Marketplace metadata.pluginRoot must be "./".', ['metadata', 'pluginRoot']); + if (!Array.isArray(marketplace.plugins) + || marketplace.plugins.length === 0 + || marketplace.plugins.some(entry => !isRecord(entry))) { + report(context, 'CLAUDE_MARKETPLACE_PLUGIN_REQUIRED', 'Marketplace must contain one or more Plugin entries.', ['plugins']); + return; + } + /** 已验证来源用于阻止两个条目指向同一 Plugin 根。 */ + const sources = new Set(); + /** 已验证名称用于阻止 Marketplace 内出现选择器歧义。 */ + const names = new Set(); + /** [index, entryValue] 表示当前 Marketplace Plugin 条目。 */ + for (const [index, entryValue] of marketplace.plugins.entries()) { + /** plugins 已经整体通过对象检查后的当前条目。 */ + const entry = entryValue as JsonRecord; + for (const field of Object.keys(entry)) { + if (!MARKETPLACE_PLUGIN_FIELDS.has(field)) + report(context, 'CLAUDE_MARKETPLACE_PLUGIN_FIELD_UNKNOWN', `Unknown Marketplace Plugin field "${field}".`, ['plugins', index, field]); + } + /** 当前条目声明的本地 Plugin 来源。 */ + const source = entry.source; + /** 单项保持兼容根布局,多项必须各自进入稳定 plugins 子目录。 */ + const sourceValid = typeof source === 'string' + && (marketplace.plugins.length === 1 ? source === './' : MARKETPLACE_PLUGIN_SOURCE_PATTERN.test(source)); + if (!sourceValid) { + report(context, 'CLAUDE_MARKETPLACE_SOURCE_INVALID', 'Single-Plugin source must be "./"; multi-Plugin sources must use "./plugins/".', ['plugins', index, 'source']); + continue; + } + if (sources.has(source)) + report(context, 'CLAUDE_MARKETPLACE_SOURCE_DUPLICATE', 'Marketplace Plugin sources must be unique.', ['plugins', index, 'source']); + sources.add(source); + /** `./` 对应 Distribution 根,其余来源去掉协议前缀后作为 Plugin 根。 */ + const pluginRoot = source === './' ? '' : source.slice(2); + /** 当前来源根内必须存在且可解析的 Claude Code Plugin Manifest。 */ + const plugin = await readJson(context, pluginRoot === '' ? PLUGIN_MANIFEST_PATH : `${pluginRoot}/${PLUGIN_MANIFEST_PATH}`); + if (plugin === undefined) + continue; + await validatePluginManifest(context, plugin, pluginRoot); + if (entry.name !== plugin.name || entry.version !== plugin.version || entry.description !== plugin.description) { + report(context, 'CLAUDE_MARKETPLACE_PLUGIN_MISMATCH', 'Marketplace Plugin metadata must match its bundled Plugin manifest.', ['plugins', index]); + } + if (typeof entry.name === 'string') { + /** Marketplace 名称使用平台选择器的大小写敏感规范值。 */ + const name = entry.name; + if (names.has(name)) + report(context, 'CLAUDE_MARKETPLACE_PLUGIN_DUPLICATE', 'Marketplace Plugin names must be unique.', ['plugins', index, 'name']); + names.add(name); + } + if (entry.strict !== true) + report(context, 'CLAUDE_MARKETPLACE_STRICT_REQUIRED', 'Self-contained Marketplace Plugins must use strict: true.', ['plugins', index, 'strict']); + // 当前单 Plugin 兼容布局继续要求 Marketplace 根元数据与唯一 Plugin 一致。 + if (marketplace.plugins.length === 1 + && (marketplace.description !== plugin.description || marketplace.version !== plugin.version)) { + report(context, 'CLAUDE_MARKETPLACE_METADATA_MISMATCH', 'Single-Plugin Marketplace description and version must match the bundled Plugin.', []); + } + } +} diff --git a/packages/platforms/claude-code/src/package/validation/mcp.ts b/packages/platforms/claude-code/src/package/validation/mcp.ts new file mode 100644 index 0000000..f6fb4b2 --- /dev/null +++ b/packages/platforms/claude-code/src/package/validation/mcp.ts @@ -0,0 +1,135 @@ +/** Claude Code MCP wire contract validator。 */ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { JsonValue } from '@tokenroll/acplugin/sdk'; +import { + isRecord, + report, + type PlatformValidateContext, +} from './shared.js'; + +/** MCP Server key 使用与 Plugin 身份一致的稳定 lowercase-kebab 规则。 */ +const PLUGIN_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** Claude Code MCP 配置文件唯一允许的包装字段。 */ +const MCP_CONFIG_FIELDS = new Set(['mcpServers']); + +/** Claude Code Plugin-local stdio MCP descriptor 字段。 */ +const MCP_STDIO_FIELDS = new Set(['type', 'command', 'args', 'env']); + +/** Claude Code 远程 HTTP MCP descriptor 字段。 */ +const MCP_HTTP_FIELDS = new Set(['type', 'url', 'headers', 'oauth']); + +/** 校验字符串映射,不允许 headers/env 退化为任意 JSON。 */ +function validateStringMap( + context: PlatformValidateContext, + value: JsonValue | undefined, + code: string, + label: string, + fieldPath: readonly (string | number)[], +): void { + if (value !== undefined && (!isRecord(value) + || Object.entries(value).some(([key, entry]) => key.trim().length === 0 || typeof entry !== 'string'))) { + report(context, code, `${label} must map non-empty names to string values.`, fieldPath); + } +} + +/** 校验 Claude Code 最终将加载的 MCP Server 映射。 */ +export function validateMcpServers( + context: PlatformValidateContext, + value: JsonValue, + fieldPath: readonly (string | number)[], +): void { + if (!isRecord(value)) { + report(context, 'CLAUDE_MCP_SERVERS_INVALID', 'mcpServers must contain a Server object mapping.', fieldPath); + return; + } + for (const [id, candidate] of Object.entries(value)) { + /** 当前 Server 在最终配置中的字段路径。 */ + const serverPath = [...fieldPath, id]; + if (!PLUGIN_NAME_PATTERN.test(id) || !isRecord(candidate)) { + report(context, 'CLAUDE_MCP_SERVER_INVALID', 'MCP Server ids must use lowercase kebab-case and map to objects.', serverPath); + continue; + } + /** type 决定 stdio 与 HTTP 的精确字段集合。 */ + const fields = candidate.type === 'stdio' + ? MCP_STDIO_FIELDS + : candidate.type === 'http' + ? MCP_HTTP_FIELDS + : undefined; + if (fields === undefined) { + report(context, 'CLAUDE_MCP_TRANSPORT_INVALID', 'MCP Server type must be stdio or http.', [...serverPath, 'type']); + continue; + } + for (const field of Object.keys(candidate)) { + if (!fields.has(field)) + report(context, 'CLAUDE_MCP_FIELD_UNKNOWN', `Unknown Claude Code MCP field "${field}".`, [...serverPath, field]); + } + if (candidate.type === 'stdio') { + if (typeof candidate.command !== 'string' || candidate.command.trim().length === 0) + report(context, 'CLAUDE_MCP_COMMAND_INVALID', 'stdio MCP command must be a non-empty string.', [...serverPath, 'command']); + if (candidate.args !== undefined + && (!Array.isArray(candidate.args) || candidate.args.some(argument => typeof argument !== 'string'))) { + report(context, 'CLAUDE_MCP_ARGS_INVALID', 'stdio MCP args must contain only strings.', [...serverPath, 'args']); + } + validateStringMap(context, candidate.env, 'CLAUDE_MCP_ENV_INVALID', 'stdio MCP env', [...serverPath, 'env']); + continue; + } + if (typeof candidate.url !== 'string') { + report(context, 'CLAUDE_MCP_URL_INVALID', 'HTTP MCP url must be an HTTP(S) URL without credentials.', [...serverPath, 'url']); + } else { + try { + /** 远程地址不得把凭据内联到 URL。 */ + const url = new URL(candidate.url); + if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username !== '' || url.password !== '') + throw new TypeError('unsafe'); + } catch { + report(context, 'CLAUDE_MCP_URL_INVALID', 'HTTP MCP url must be an HTTP(S) URL without credentials.', [...serverPath, 'url']); + } + } + validateStringMap(context, candidate.headers, 'CLAUDE_MCP_HEADERS_INVALID', 'HTTP MCP headers', [...serverPath, 'headers']); + if (candidate.oauth !== undefined) { + if (!isRecord(candidate.oauth)) { + report(context, 'CLAUDE_MCP_OAUTH_INVALID', 'HTTP MCP oauth must be an object.', [...serverPath, 'oauth']); + } else { + for (const field of Object.keys(candidate.oauth)) { + if (field !== 'scopes') + report(context, 'CLAUDE_MCP_OAUTH_FIELD_UNKNOWN', `Unknown Claude Code MCP OAuth field "${field}".`, [...serverPath, 'oauth', field]); + } + if (candidate.oauth.scopes !== undefined + && (typeof candidate.oauth.scopes !== 'string' || candidate.oauth.scopes.trim().length === 0)) { + report(context, 'CLAUDE_MCP_OAUTH_INVALID', 'HTTP MCP oauth.scopes must be a non-empty string.', [...serverPath, 'oauth', 'scopes']); + } + } + } + } +} + +/** 读取并校验 Plugin 根内被引用的 Claude Code MCP 配置。 */ +export async function validateMcpFile( + context: PlatformValidateContext, + pluginRoot: string, + reference: string, + fieldPath: readonly (string | number)[], +): Promise { + try { + /** MCP 配置引用相对于当前 Plugin 根解析。 */ + const mcpPath = path.join(context.candidate.root, pluginRoot, reference.slice(2)); + /** 被引用文件必须使用 `{ mcpServers }` 包装。 */ + const value: unknown = JSON.parse(await fs.readFile(mcpPath, 'utf8')); + if (!isRecord(value)) { + report(context, 'CLAUDE_MCP_CONFIG_INVALID', 'MCP config must contain a JSON object.', fieldPath); + return; + } + for (const field of Object.keys(value)) { + if (!MCP_CONFIG_FIELDS.has(field)) + report(context, 'CLAUDE_MCP_CONFIG_FIELD_UNKNOWN', `Unknown Claude Code MCP config field "${field}".`, [...fieldPath, field]); + } + if (value.mcpServers === undefined) + report(context, 'CLAUDE_MCP_SERVERS_REQUIRED', 'MCP config must contain mcpServers.', [...fieldPath, 'mcpServers']); + else + validateMcpServers(context, value.mcpServers, [...fieldPath, 'mcpServers']); + } catch { + report(context, 'CLAUDE_MCP_CONFIG_READ_FAILED', 'MCP config reference must contain valid JSON.', fieldPath); + } +} diff --git a/packages/platforms/claude-code/src/package/validation/shared.ts b/packages/platforms/claude-code/src/package/validation/shared.ts new file mode 100644 index 0000000..7c29cc2 --- /dev/null +++ b/packages/platforms/claude-code/src/package/validation/shared.ts @@ -0,0 +1,126 @@ +/** Claude Code candidate validator 共用的只读边界。 */ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { JsonValue, ValidatePackageContext } from '@tokenroll/acplugin/sdk'; + +/** Claude Code validator 只消费 SDK 的最终 Package candidate Context。 */ +export type PlatformValidateContext = ValidatePackageContext; + +/** JSON 对象的运行时只读索引类型。 */ +export type JsonRecord = Record; + +/** + * 判断未知值是否为非数组 JSON 对象。 + * + * @param value 从候选清单解析的未知 JSON 值。 + * @returns 可以按字段读取时返回 true。 + */ +export function isRecord(value: unknown): value is JsonRecord { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * 向 Core 提交 Claude Code 候选校验错误。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param code 稳定诊断码。 + * @param message 不包含宿主绝对路径的错误信息。 + * @param fieldPath 可选的清单字段路径。 + */ +export function report( + context: PlatformValidateContext, + code: string, + message: string, + fieldPath?: readonly (string | number)[], +): void { + context.diagnostics.report({ + code, + severity: 'error', + message, + ...(fieldPath === undefined ? {} : { fieldPath }), + }); +} + +/** + * 从候选安装根读取并解析 JSON 文件。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param artifactPath 候选根内的规范 Asset 路径。 + * @returns JSON 对象;缺失或格式错误时提交诊断并返回 undefined。 + */ +export async function readJson( + context: PlatformValidateContext, + artifactPath: string, +): Promise { + try { + /** 从已由 Core 安全物化的候选根读取清单文本。 */ + const source = await fs.readFile(path.join(context.candidate.root, artifactPath), 'utf8'); + /** JSON.parse 返回的未知值必须继续验证顶层对象形态。 */ + const value: unknown = JSON.parse(source); + if (!isRecord(value)) { + report(context, 'CLAUDE_MANIFEST_OBJECT_REQUIRED', `${artifactPath} must contain a JSON object.`); + return undefined; + } + return value; + } catch { + report(context, 'CLAUDE_MANIFEST_READ_FAILED', `${artifactPath} must be present and contain valid JSON.`); + return undefined; + } +} + +/** + * 判断清单路径引用是否严格位于当前 Plugin 安装根。 + * + * @param reference Claude Code 清单中的相对路径。 + * @returns 使用 `./`、不逃逸且不指向根本身时返回 true。 + */ +export function isSafePluginReference(reference: string): boolean { + if (!reference.startsWith('./') || reference.includes('\\') || reference.includes('\0')) + return false; + /** 去除协议要求的 `./` 后执行 POSIX 规范化。 */ + const relative = reference.slice(2); + /** 规范化后的路径用于拒绝空路径、绝对路径和父目录逃逸。 */ + const normalized = path.posix.normalize(relative); + return relative.length > 0 + && normalized !== '.' + && normalized !== '..' + && !normalized.startsWith('../') + && !path.posix.isAbsolute(normalized); +} + +/** + * 判断候选 Asset 集合是否满足文件或目录引用。 + * + * @param assets 当前 Package 的全部规范 Asset 路径。 + * @param reference 已通过安全规则校验的 Claude Code 路径引用。 + * @returns 精确文件或目录前缀至少匹配一个 Asset 时返回 true。 + */ +export function referenceExists(assets: ReadonlySet, reference: string): boolean { + /** 清单引用去除固定 `./` 后的 Asset 路径。 */ + const target = reference.slice(2).replace(/\/+$/u, ''); + if (assets.has(target)) + return true; + for (const asset of assets) { + if (asset.startsWith(`${target}/`)) + return true; + } + return false; +} + +/** + * 把 Distribution 中某个 Plugin 子树转换为安装根相对 Asset 集合。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param pluginRoot Plugin 相对于 Distribution 根的无前导点路径。 + * @returns 去掉 Plugin 根前缀后的 Asset 路径集合。 + */ +export function scopedAssets(context: PlatformValidateContext, pluginRoot: string): ReadonlySet { + /** 根 Plugin 不需要过滤或裁剪路径。 */ + if (pluginRoot === '') + return new Set(context.candidate.unit.assets.map(asset => asset.path)); + /** 嵌套 Plugin 全部 Asset 共同使用的固定目录前缀。 */ + const prefix = `${pluginRoot}/`; + return new Set(context.candidate.unit.assets + .filter(asset => asset.path.startsWith(prefix)) + .map(asset => asset.path.slice(prefix.length))); +} diff --git a/packages/platforms/claude-code/src/types.ts b/packages/platforms/claude-code/src/types.ts new file mode 100644 index 0000000..a9ab7cd --- /dev/null +++ b/packages/platforms/claude-code/src/types.ts @@ -0,0 +1,100 @@ +/** Claude Code Marketplace 所有者的显式身份。 */ +export interface ClaudeCodeMarketplaceOwner { + readonly name: string; + readonly email?: string; + readonly url?: string; +} + +/** Claude Code Marketplace 的平台专属根级展示选项。 */ +export interface ClaudeCodeMarketplaceOptions { + readonly name?: string; + readonly owner?: ClaudeCodeMarketplaceOwner; + readonly category?: string; + readonly tags?: readonly string[]; +} + +/** 创建 Claude Code Platform 时可声明的公开选项。 */ +export interface ClaudeCodePlatformOptions { + readonly strict?: boolean; + readonly defaultEnabled?: boolean; + readonly marketplace?: ClaudeCodeMarketplaceOptions; +} + +/** Claude Code Platform 允许 Extension 在 finalization 提交的私有 Component 联合。 */ +export type ClaudePackageComponent = ClaudeNativeAgentComponent; + +/** + * Claude Code 原生 Agent 的 Platform-owned contribution 数据。 + * + * 它不是 Canonical Agent,也不复用 Core 的 Agent model/capability 类型;每个字段 + * 的运行时校验、名称冲突、frontmatter 和安装路径都由本 Platform 独立拥有。 + */ +export type ClaudeNativeAgentComponent = import('@tokenroll/acplugin/sdk').JsonObject & Readonly<{ + readonly kind: 'native-agent'; + readonly id: string; + readonly description: string; + readonly body: string; + readonly model?: 'inherit' | 'fast' | 'capable'; + readonly tools?: readonly string[]; + readonly disallowedTools?: readonly string[]; + readonly effort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max'; + readonly maxTurns?: number; + readonly skills?: readonly string[]; + readonly memory?: 'user' | 'project' | 'local'; + readonly background?: boolean; + readonly isolation?: 'worktree'; +}>; + +/** Claude Code Plugin 清单中可由 Platform 和 Extension 共同组成的字段。 */ +export interface ClaudeCodePluginManifest { + readonly name: string; + readonly version: string; + readonly description: string; + readonly displayName?: string; + readonly author?: { + readonly name: string; + readonly email?: string; + readonly url?: string; + }; + readonly homepage?: string; + readonly repository?: string; + readonly license?: string; + readonly keywords?: readonly string[]; + readonly defaultEnabled?: boolean; + readonly commands?: string; + readonly skills?: string; + readonly agents?: string; + readonly hooks?: string | Readonly>; + readonly mcpServers?: string | Readonly>; +} + +/** Claude Code 自包含 Marketplace 中一个 Plugin 的相对安装根。 */ +export type ClaudeCodeMarketplacePluginSource = './' | `./plugins/${string}`; + +/** Claude Code Marketplace 文件中的单个 Plugin 条目。 */ +export interface ClaudeCodeMarketplacePlugin { + readonly name: string; + readonly source: ClaudeCodeMarketplacePluginSource; + readonly description: string; + readonly version: string; + readonly author?: ClaudeCodePluginManifest['author']; + readonly homepage?: string; + readonly repository?: string; + readonly license?: string; + readonly keywords?: readonly string[]; + readonly category?: string; + readonly tags?: readonly string[]; + readonly strict: true; +} + +/** Claude Code 自包含 Marketplace 的根清单。 */ +export interface ClaudeCodeMarketplaceManifest { + readonly name: string; + readonly owner: ClaudeCodeMarketplaceOwner; + readonly description: string; + readonly version: string; + readonly metadata: { + readonly pluginRoot: './'; + }; + readonly plugins: readonly ClaudeCodeMarketplacePlugin[]; +} diff --git a/packages/platforms/claude-code/test/golden/.claude-plugin/marketplace.json b/packages/platforms/claude-code/test/golden/.claude-plugin/marketplace.json new file mode 100644 index 0000000..8905ee7 --- /dev/null +++ b/packages/platforms/claude-code/test/golden/.claude-plugin/marketplace.json @@ -0,0 +1,34 @@ +{ + "description": "Release workflow tools.", + "metadata": { + "pluginRoot": "./" + }, + "name": "release-tools-marketplace", + "owner": { + "email": "maintainers@example.com", + "name": "TokenRoll", + "url": "https://github.com/TokenRollAI" + }, + "plugins": [ + { + "author": { + "email": "maintainers@example.com", + "name": "TokenRoll", + "url": "https://github.com/TokenRollAI" + }, + "description": "Release workflow tools.", + "homepage": "https://example.com/release-tools", + "keywords": [ + "release", + "review" + ], + "license": "MIT", + "name": "release-tools", + "repository": "https://github.com/TokenRollAI/release-tools", + "source": "./", + "strict": true, + "version": "1.2.3" + } + ], + "version": "1.2.3" +} diff --git a/packages/platforms/claude-code/test/golden/.claude-plugin/plugin.json b/packages/platforms/claude-code/test/golden/.claude-plugin/plugin.json new file mode 100644 index 0000000..2517fe1 --- /dev/null +++ b/packages/platforms/claude-code/test/golden/.claude-plugin/plugin.json @@ -0,0 +1,22 @@ +{ + "agents": "./agents/", + "author": { + "email": "maintainers@example.com", + "name": "TokenRoll", + "url": "https://github.com/TokenRollAI" + }, + "commands": "./commands/", + "defaultEnabled": false, + "description": "Release workflow tools.", + "displayName": "Release Tools", + "homepage": "https://example.com/release-tools", + "keywords": [ + "release", + "review" + ], + "license": "MIT", + "name": "release-tools", + "repository": "https://github.com/TokenRollAI/release-tools", + "skills": "./skills/", + "version": "1.2.3" +} diff --git a/packages/platforms/claude-code/test/golden/agents/reviewer.md b/packages/platforms/claude-code/test/golden/agents/reviewer.md new file mode 100644 index 0000000..3715e27 --- /dev/null +++ b/packages/platforms/claude-code/test/golden/agents/reviewer.md @@ -0,0 +1,15 @@ +--- +background: false +description: Review code changes. +disallowedTools: Write +effort: high +isolation: worktree +maxTurns: 8 +memory: project +model: sonnet +name: reviewer +skills: + - review +tools: Read, Grep +--- +Review code and report findings. diff --git a/packages/platforms/claude-code/test/golden/commands/release.md b/packages/platforms/claude-code/test/golden/commands/release.md new file mode 100644 index 0000000..2a65d18 --- /dev/null +++ b/packages/platforms/claude-code/test/golden/commands/release.md @@ -0,0 +1,8 @@ +--- +allowed-tools: + - Read +argument-hint: +description: Prepare a release. +model: sonnet +--- +Prepare release $ARGUMENTS. diff --git a/packages/platforms/claude-code/test/golden/skills/review/SKILL.md b/packages/platforms/claude-code/test/golden/skills/review/SKILL.md new file mode 100644 index 0000000..086a201 --- /dev/null +++ b/packages/platforms/claude-code/test/golden/skills/review/SKILL.md @@ -0,0 +1,12 @@ +--- +agent: reviewer +allowed-tools: + - Read + - Grep +context: fork +description: Review the current change. +disable-model-invocation: false +name: review +user-invocable: false +--- +Review the implementation. diff --git a/packages/platforms/claude-code/test/platform.test.ts b/packages/platforms/claude-code/test/platform.test.ts new file mode 100644 index 0000000..25ff8df --- /dev/null +++ b/packages/platforms/claude-code/test/platform.test.ts @@ -0,0 +1,520 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + resolveKernelConfig, + runKernelBuildSession, + type ConfigCommand, +} from '@acplugin/core'; +import { defineExtension, type AcpluginExtension, type PlatformContributor } from '@tokenroll/acplugin/sdk'; +import { claudeCode, type ClaudePackageComponent } from '../src/index.js'; +import { MARKETPLACE_MANIFEST_PATH, PLUGIN_MANIFEST_PATH } from '../src/package/manifest.js'; + +/** 测试结束后统一删除的临时工程根。 */ +const temporaryRoots: string[] = []; + +/** Golden 文件相对于当前测试模块的固定目录。 */ +const goldenRoot = path.join(import.meta.dirname, 'golden'); + +/** 创建带最小配置占位符且会自动清理的临时工程。 */ +async function temporaryProject(): Promise { + /** root 是当前测试独占的工程根。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-claude-platform-')); + temporaryRoots.push(root); + await fs.mkdir(path.join(root, 'src'), { recursive: true }); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), 'export default {}\n'); + return root; +} + +/** 写入覆盖 canonical Component、Public 与内建 Runtime 的完整工程。 */ +async function writeCompleteProject(root: string): Promise { + await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/skills/review/references'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/agents'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/runtime'), { recursive: true }); + await fs.mkdir(path.join(root, 'public/shared'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/commands/release.md'), `--- +description: Prepare a release. +argumentHint: +platforms: + claude-code: + allowedTools: + - Read + model: sonnet +--- +Prepare release {{arguments}}. +`); + await fs.writeFile(path.join(root, 'src/skills/review/SKILL.md'), `--- +description: Review the current change. +invocation: + user: false + model: true +platforms: + claude-code: + allowedTools: + - Read + - Grep + context: fork + agent: reviewer +--- +Review the implementation. +`); + await fs.writeFile(path.join(root, 'src/skills/review/references/checklist.md'), 'Review checklist.\n'); + await fs.writeFile(path.join(root, 'src/agents/reviewer.md'), `--- +description: Review code changes. +model: capable +capabilities: + - filesystem:read + - search +platforms: + claude-code: + tools: + - Read + - Grep + disallowedTools: + - Write + effort: high + maxTurns: 8 + skills: + - review + memory: project + background: false + isolation: worktree +--- +Review code and report findings. +`); + await fs.writeFile(path.join(root, 'src/runtime/cli.ts'), 'process.stdout.write("runtime-ready\\n");\n'); + await fs.writeFile(path.join(root, 'public/shared/logo.bin'), Buffer.from([0, 1, 2, 255])); +} + +/** 执行一次只包含 Claude Code 的真实 Kernel v2 BuildSession。 */ +async function run(input: { + readonly root: string; + readonly command?: ConfigCommand; + readonly platform?: ReturnType; + readonly extensions?: readonly AcpluginExtension[]; + readonly commit?: boolean; +}) { + /** command 同时控制报告语义,commit 只在 build 时实际生效。 */ + const command = input.command ?? 'build'; + /** resolved 使用与公开 Project API 相同的严格配置边界。 */ + const resolved = resolveKernelConfig({ + name: 'release-tools', + version: '1.2.3', + description: 'Release workflow tools.', + displayName: 'Release Tools', + author: { name: 'TokenRoll', email: 'maintainers@example.com', url: 'https://github.com/TokenRollAI' }, + homepage: 'https://example.com/release-tools', + repository: 'https://github.com/TokenRollAI/release-tools', + license: 'MIT', + keywords: ['release', 'review'], + platforms: [input.platform ?? claudeCode()], + extensions: input.extensions ?? [], + build: { outDir: 'dist', strict: true }, + }, { + projectRoot: input.root, + configFile: path.join(input.root, 'acplugin.config.ts'), + command, + mode: 'production', + }); + expect(resolved.diagnostics).toEqual([]); + return (await runKernelBuildSession({ + config: resolved.config!, + frameworkVersion: 'test', + commit: command === 'build' && (input.commit ?? true), + })).report; +} + +/** 对比构建结果和仓库内确定性 Golden 字节。 */ +async function expectGolden(actual: string, golden: string): Promise { + await expect(fs.readFile(actual)).resolves.toEqual(await fs.readFile(path.join(goldenRoot, golden))); +} + +/** 创建通过 add-only Package Contribution 注入最终清单字段的测试 Extension。 */ +function wireExtension(input: { + readonly id: string; + readonly field?: 'hooks' | 'mcpServers'; + readonly value?: string; + readonly path?: string; + readonly bytes?: string; +}): AcpluginExtension { + return defineExtension({ + id: input.id, + apiVersion: '1', + resourceRoots: [], + /** 测试 Session 最小实现 discover/validate/build/contribute 四段契约。 */ + createSession: () => ({ + /** 空对象足以标记当前 Fixture 本轮已发现。 */ + discover: () => ({}), + /** 每个 Fixture 声明一个必须由 Contributor 完整覆盖的 tuple。 */ + validate: (_context, discovered) => ({ + state: discovered, + subjects: [{ subject: `fixture:${input.id}`, capabilities: ['delivery'] }], + }), + /** 需要文件时只通过 owner-scoped AssetService 创建字节。 */ + async build({ assets }, validated) { + /** asset 只在当前 Extension owner scope 中签发。 */ + const asset = input.path === undefined + ? undefined + : await assets.fromBytes({ + bytes: input.bytes ?? '{}\n', + origin: { operation: 'wire-fixture', subjects: [`fixture:${input.id}`] }, + }); + return { state: { validated, ...(asset === undefined ? {} : { asset }) } }; + }, + contributors: [{ + platform: 'claude-code', + platformApiVersion: '1', + /** Contributor 只占用一个声明点并可追加自己的 Asset。 */ + contribute: (_context, built) => ({ + ...(input.field === undefined || input.value === undefined + ? {} + : { documentFields: [{ document: 'plugin-manifest', path: [input.field], value: input.value }] }), + ...(built.asset === undefined ? {} : { assets: [{ path: input.path!, asset: built.asset }] }), + compatibility: [{ + subject: `fixture:${input.id}`, + capability: 'delivery', + level: 'native', + reason: 'The fixture is delivered through a declared Claude Code extension point.', + }], + }), + }], + }), + }); +} + +/** 创建只通过 Claude Platform Component transport 交付 Native Agent 的中立测试 Extension。 */ +function nativeAgentExtension(input: { + readonly id: string; + readonly agents: readonly { readonly id: string; readonly description?: string; readonly body?: string }[]; +}): AcpluginExtension { + const contributor: PlatformContributor, ClaudePackageComponent> = { + platform: 'claude-code', + platformApiVersion: '1', + contribute: () => ({ + components: input.agents.map(agent => ({ + subject: `fixture:${input.id}`, + value: { + kind: 'native-agent' as const, + id: agent.id, + description: agent.description ?? `Native ${agent.id}.`, + body: agent.body ?? `Perform ${agent.id}.`, + model: 'capable' as const, + tools: ['Read', 'Grep'], + }, + })), + compatibility: [{ + subject: `fixture:${input.id}`, + capability: 'delivery', + level: 'native' as const, + reason: 'The fixture is delivered as a native Claude Code Agent.', + }], + }), + }; + return defineExtension({ + id: input.id, + apiVersion: '1', + resourceRoots: [], + createSession: () => ({ + discover: () => ({}), + validate: (_context, discovered) => ({ state: discovered, subjects: [{ subject: `fixture:${input.id}`, capabilities: ['delivery'] }] }), + build: (_context, validated) => ({ state: validated }), + contributors: [contributor], + }), + }); +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('Claude Code Platform Package API', () => { + it('builds native Components, Public, Core Runtime, metadata, and deterministic Documents', async () => { + /** root 承载当前完整能力测试的隔离工程。 */ + const root = await temporaryProject(); + await writeCompleteProject(root); + /** report 来自真实 build 与受管事务提交。 */ + const report = await run({ root, platform: claudeCode({ defaultEnabled: false }) }); + /** output 是事务提交后的主 Plugin 根。 */ + const output = path.join(root, 'dist/claude-code/plugin'); + + expect(report.success, JSON.stringify(report.diagnostics, null, 2)).toBe(true); + expect(report.committed).toBe(true); + expect(report.packages).toHaveLength(1); + expect(report.compatibility).toEqual(expect.arrayContaining([ + expect.objectContaining({ subject: 'command:release', capability: 'component', level: 'native' }), + expect.objectContaining({ subject: 'skill:review', capability: 'component', level: 'native' }), + expect.objectContaining({ subject: 'agent:reviewer', capability: 'component', level: 'native' }), + expect.objectContaining({ subject: 'runtime:cli', capability: 'node20-esm', level: 'native' }), + ])); + expect(report.metadata).toContainEqual(expect.objectContaining({ field: 'author.email', disposition: 'emitted' })); + await expectGolden(path.join(output, PLUGIN_MANIFEST_PATH), PLUGIN_MANIFEST_PATH); + await expectGolden(path.join(output, 'commands/release.md'), 'commands/release.md'); + await expectGolden(path.join(output, 'skills/review/SKILL.md'), 'skills/review/SKILL.md'); + await expectGolden(path.join(output, 'agents/reviewer.md'), 'agents/reviewer.md'); + await expect(fs.readFile(path.join(output, 'skills/review/references/checklist.md'), 'utf8')).resolves.toBe('Review checklist.\n'); + await expect(fs.readFile(path.join(output, 'shared/logo.bin'))).resolves.toEqual(Buffer.from([0, 1, 2, 255])); + await expect(fs.readFile(path.join(output, 'runtime/cli/main.mjs'), 'utf8')).resolves.toContain('runtime-ready'); + expect(report.packages[0]?.assets.find(asset => asset.path === 'runtime/cli/main.mjs')).toMatchObject({ + owner: 'framework:node-runtime', mode: 0o755, origin: { type: 'compile', profile: 'portable-node' }, + }); + }); + + it('lets independent Extensions add only declared Hooks and MCP fields and Assets', async () => { + /** root 不含 canonical Component,只验证两个 add-only extension points。 */ + const root = await temporaryProject(); + /** hooks 独占 hooks 字段和对应配置文件。 */ + const hooks = wireExtension({ + id: 'hooks-fixture', field: 'hooks', value: './hooks/hooks.json', path: 'hooks/hooks.json', + bytes: '{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"node runner.mjs"}]}]}}\n', + }); + /** mcp 独占 mcpServers 字段和对应配置文件。 */ + const mcp = wireExtension({ + id: 'mcp-fixture', field: 'mcpServers', value: './.mcp.json', path: '.mcp.json', + bytes: '{"mcpServers":{}}\n', + }); + /** report 必须同时保留两个 Extension 的真实 Asset owner。 */ + const report = await run({ root, extensions: [hooks, mcp] }); + /** manifest 是经过 Core 集中 contribution 合并后序列化的 Document。 */ + const manifest = JSON.parse(await fs.readFile(path.join(root, 'dist/claude-code/plugin', PLUGIN_MANIFEST_PATH), 'utf8')); + + expect(report.success, JSON.stringify(report.diagnostics, null, 2)).toBe(true); + expect(manifest).toMatchObject({ hooks: './hooks/hooks.json', mcpServers: './.mcp.json' }); + expect(report.packages[0]?.assets).toEqual(expect.arrayContaining([ + expect.objectContaining({ path: 'hooks/hooks.json', owner: 'extension:hooks-fixture' }), + expect.objectContaining({ path: '.mcp.json', owner: 'extension:mcp-fixture' }), + ])); + }); + + it('renders a Platform-owned Native Agent contribution, registers it in the Manifest, and preserves provenance', async () => { + const root = await temporaryProject(); + const report = await run({ root, extensions: [nativeAgentExtension({ id: 'private-fixture', agents: [{ id: 'observer' }] })] }); + const output = path.join(root, 'dist/claude-code/plugin'); + const manifest = JSON.parse(await fs.readFile(path.join(output, PLUGIN_MANIFEST_PATH), 'utf8')); + const agent = await fs.readFile(path.join(output, 'agents/observer.md'), 'utf8'); + const agentAsset = report.packages[0]!.assets.find(asset => asset.path === 'agents/observer.md')!; + const manifestAsset = report.packages[0]!.assets.find(asset => asset.path === PLUGIN_MANIFEST_PATH)!; + + expect(report.success, JSON.stringify(report.diagnostics, null, 2)).toBe(true); + expect(manifest.agents).toBe('./agents/'); + expect(agent).toContain('name: observer'); + expect(agentAsset).toMatchObject({ + owner: 'platform:claude-code', + origin: { contributors: [{ owner: 'extension:private-fixture', subject: 'fixture:private-fixture' }] }, + }); + expect(manifestAsset).toMatchObject({ + origin: { contributors: [{ owner: 'extension:private-fixture', subject: 'fixture:private-fixture' }] }, + }); + }); + + it('keeps canonical and contributed Agents in one Platform namespace and rejects deterministic collisions', async () => { + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/agents'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/agents/existing.md'), '---\ndescription: Existing Agent.\n---\nExisting.\n'); + const collision = await run({ + root, + command: 'validate', + commit: false, + extensions: [nativeAgentExtension({ id: 'collision-fixture', agents: [{ id: 'existing' }] })], + }); + expect(collision.success).toBe(false); + expect(collision.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CLAUDE_COMPONENT_CONTRIBUTION_COLLISION', phase: 'finalize', platform: 'claude-code', + })); + + const invalidRoot = await temporaryProject(); + const invalid = await run({ + root: invalidRoot, + command: 'validate', + commit: false, + extensions: [nativeAgentExtension({ id: 'invalid-fixture', agents: [{ id: 'EXISTING' }] })], + }); + expect(invalid.success).toBe(false); + expect(invalid.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CLAUDE_COMPONENT_CONTRIBUTION_INVALID', phase: 'finalize', platform: 'claude-code', + })); + + const duplicateRoot = await temporaryProject(); + const extensions = [ + nativeAgentExtension({ id: 'zeta-fixture', agents: [{ id: 'same' }] }), + nativeAgentExtension({ id: 'alpha-fixture', agents: [{ id: 'same' }] }), + ]; + const first = await run({ root: duplicateRoot, command: 'validate', commit: false, extensions }); + const second = await run({ root: duplicateRoot, command: 'validate', commit: false, extensions: [...extensions].reverse() }); + expect(first.diagnostics).toEqual(second.diagnostics); + expect(first.success).toBe(false); + expect(first.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CLAUDE_COMPONENT_CONTRIBUTION_COLLISION', phase: 'finalize', platform: 'claude-code', + })); + }); + + it('creates a self-contained Marketplace by inheriting validated primary AssetRefs byte-for-byte', async () => { + /** root 提供足够多的 Asset 类型验证完整继承。 */ + const root = await temporaryProject(); + await writeCompleteProject(root); + /** platform 开启唯一可选 Distribution。 */ + const platform = claudeCode({ marketplace: {} }); + /** first 用于建立确定性报告和字节基线。 */ + const contribution = nativeAgentExtension({ id: 'marketplace-fixture', agents: [{ id: 'contributed' }] }); + const first = await run({ root, platform, extensions: [contribution] }); + /** firstDistribution 保存首次构建的完整分发报告。 */ + const firstDistribution = first.packages.find(unit => unit.id === 'marketplace')!; + /** primary 与 distribution 使用相同 AssetRef,因此报告 hash/origin 必须一致。 */ + const primary = first.packages.find(unit => unit.id === 'plugin')!; + const contributedPrimary = primary.assets.find(asset => asset.path === 'agents/contributed.md')!; + const contributedDistribution = firstDistribution.assets.find(asset => asset.path === 'agents/contributed.md')!; + /** second 使用相同输入验证完整事务替换不改变字节。 */ + const second = await run({ root, platform, extensions: [contribution] }); + /** marketplaceRoot 是第二次原子替换后的最终分发目录。 */ + const marketplaceRoot = path.join(root, 'dist/claude-code/marketplace'); + + expect(first.success, JSON.stringify(first.diagnostics, null, 2)).toBe(true); + expect(second.success, JSON.stringify(second.diagnostics, null, 2)).toBe(true); + await expectGolden(path.join(marketplaceRoot, MARKETPLACE_MANIFEST_PATH), MARKETPLACE_MANIFEST_PATH); + await expect(fs.readFile(path.join(marketplaceRoot, PLUGIN_MANIFEST_PATH))).resolves.toEqual( + await fs.readFile(path.join(root, 'dist/claude-code/plugin', PLUGIN_MANIFEST_PATH)), + ); + for (const source of primary.assets) { + expect(firstDistribution.assets.find(asset => asset.path === source.path)).toMatchObject({ + owner: source.owner, mode: source.mode, sha256: source.sha256, origin: source.origin, + }); + } + expect(contributedDistribution).toMatchObject({ + owner: contributedPrimary.owner, + mode: contributedPrimary.mode, + sha256: contributedPrimary.sha256, + origin: contributedPrimary.origin, + }); + expect(second.packages.find(unit => unit.id === 'marketplace')?.assets).toEqual(firstDistribution.assets); + }); + + it('maps WebSearch only when portable Agent capabilities include search and network', async () => { + /** root 包含三种能力组合以验证组合授权规则。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/agents'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/agents/search-only.md'), '---\ndescription: Search local files.\ncapabilities: [search]\n---\nSearch.\n'); + await fs.writeFile(path.join(root, 'src/agents/network-only.md'), '---\ndescription: Fetch remote content.\ncapabilities: [network]\n---\nFetch.\n'); + await fs.writeFile(path.join(root, 'src/agents/web-search.md'), '---\ndescription: Search the web.\ncapabilities: [search, network]\n---\nSearch.\n'); + /** report 确认三项 Agent 都完成 native 交付。 */ + const report = await run({ root }); + /** agentsRoot 包含三个能力组合的原生 Agent 文档。 */ + const agentsRoot = path.join(root, 'dist/claude-code/plugin/agents'); + /** searchOnly 不应仅凭本地检索能力得到 WebSearch。 */ + const searchOnly = await fs.readFile(path.join(agentsRoot, 'search-only.md'), 'utf8'); + /** networkOnly 不应仅凭联网读取能力得到 WebSearch。 */ + const networkOnly = await fs.readFile(path.join(agentsRoot, 'network-only.md'), 'utf8'); + /** webSearch 同时具备两个必要 capability。 */ + const webSearch = await fs.readFile(path.join(agentsRoot, 'web-search.md'), 'utf8'); + + expect(report.success).toBe(true); + expect(searchOnly).toContain('tools: Glob, Grep'); + expect(searchOnly).not.toContain('WebSearch'); + expect(networkOnly).toContain('tools: WebFetch'); + expect(networkOnly).not.toContain('WebSearch'); + expect(webSearch).toContain('tools: Glob, Grep, WebFetch, WebSearch'); + }); + + it('rejects invalid Component namespaces before Package creation', async () => { + /** root 中的未知字段必须在 canonical validation 阶段失败。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/commands/unsafe.md'), `--- +description: Unsafe command. +platforms: + claude-code: + rawFrontmatter: true +--- +Do work. +`); + /** report 不应包含任何已建立的 Package。 */ + const report = await run({ root, command: 'validate', commit: false }); + + expect(report.success).toBe(false); + expect(report.packages).toEqual([]); + expect(report.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CLAUDE_COMPONENT_FIELD_UNKNOWN', + fieldPath: ['platforms', 'claude-code', 'rawFrontmatter'], + })); + }); + + it('rejects unsafe Extension references and invalid Hook wire data at the final candidate boundary', async () => { + /** unsafeRoot 验证路径逃逸不会越过最终候选校验。 */ + const unsafeRoot = await temporaryProject(); + /** unsafe 只贡献恶意引用,不创建逃逸目标。 */ + const unsafe = await run({ + root: unsafeRoot, + command: 'validate', + extensions: [wireExtension({ id: 'unsafe-reference', field: 'mcpServers', value: '../outside.json' })], + commit: false, + }); + expect(unsafe.success).toBe(false); + expect(unsafe.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CLAUDE_MANIFEST_REFERENCE_UNSAFE', fieldPath: ['mcpServers'], phase: 'platform-validate', + })); + + /** hookRoot 独立验证已存在文件的 wire schema。 */ + const hookRoot = await temporaryProject(); + /** invalidHook 缺少 command handler 的必填 command。 */ + const invalidHook = await run({ + root: hookRoot, + command: 'validate', + extensions: [wireExtension({ + id: 'invalid-hook', field: 'hooks', value: './hooks/hooks.json', path: 'hooks/hooks.json', + bytes: '{"hooks":{"SessionStart":[{"hooks":[{"type":"command"}]}]}}\n', + })], + commit: false, + }); + expect(invalidHook.success).toBe(false); + expect(invalidHook.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CLAUDE_HOOK_HANDLER_TARGET_INVALID', phase: 'platform-validate', + })); + + /** mcpRoot 验证容器合法时仍会深入拒绝非法 Server 字段。 */ + const mcpRoot = await temporaryProject(); + /** invalidMcp 的 headers 不是 Claude Code 协议要求的字符串映射。 */ + const invalidMcp = await run({ + root: mcpRoot, + command: 'validate', + extensions: [wireExtension({ + id: 'invalid-mcp', field: 'mcpServers', value: './.mcp.json', path: '.mcp.json', + bytes: '{"mcpServers":{"docs":{"type":"http","url":"https://example.com/mcp","headers":42}}}\n', + })], + commit: false, + }); + expect(invalidMcp.success).toBe(false); + expect(invalidMcp.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CLAUDE_MCP_HEADERS_INVALID', phase: 'platform-validate', + })); + + /** orphanRoot 不修改 Manifest,只投递 Claude 会自动发现的根 `.mcp.json`。 */ + const orphanRoot = await temporaryProject(); + /** 非法 orphan 文件必须经过与显式引用相同的深层 wire 校验。 */ + const orphanMcp = await run({ + root: orphanRoot, + command: 'validate', + extensions: [wireExtension({ + id: 'orphan-mcp', path: '.mcp.json', + bytes: '{"mcpServers":{"docs":{"type":"http","url":"https://user:pass@example.com/mcp","junk":true}}}\n', + })], + commit: false, + }); + expect(orphanMcp.success).toBe(false); + expect(orphanMcp.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'CLAUDE_MCP_FIELD_UNKNOWN', phase: 'platform-validate' }), + expect.objectContaining({ code: 'CLAUDE_MCP_URL_INVALID', phase: 'platform-validate' }), + ])); + }); + + it('validates and defensively copies Platform options at the factory boundary', () => { + expect(() => claudeCode({ defaultEnabled: 'yes' as never })).toThrow(/defaultEnabled/u); + expect(() => claudeCode({ marketplace: { name: 'Not-Kebab' } })).toThrow(/lowercase kebab-case/u); + /** input 在工厂返回后继续可变,最终 Platform options 必须保持原快照。 */ + const input = { marketplace: { tags: ['tools'] } }; + /** platform 必须复制 input 而不是保留作者对象 identity。 */ + const platform = claudeCode(input); + input.marketplace.tags.push('mutated'); + expect(platform.options).toEqual({ marketplace: { tags: ['tools'] } }); + expect(Object.isFrozen((platform.options as { marketplace: object }).marketplace)).toBe(true); + }); +}); diff --git a/packages/platforms/claude-code/tsconfig.json b/packages/platforms/claude-code/tsconfig.json new file mode 100644 index 0000000..3ae4da2 --- /dev/null +++ b/packages/platforms/claude-code/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/platforms/claude-code/tsdown.config.ts b/packages/platforms/claude-code/tsdown.config.ts new file mode 100644 index 0000000..1ef53af --- /dev/null +++ b/packages/platforms/claude-code/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsdown'; + +/** Claude Code Platform 包使用统一 Node ESM 与声明输出。 */ +export default defineConfig({ + entry: ['./src/index.ts'], + format: ['esm'], + platform: 'node', + target: 'node20', + dts: { generator: 'oxc' }, + clean: true, + sourcemap: false, + deps: { neverBundle: ['@tokenroll/acplugin'] }, +}); diff --git a/packages/platforms/claude-code/vitest.config.ts b/packages/platforms/claude-code/vitest.config.ts new file mode 100644 index 0000000..7e56341 --- /dev/null +++ b/packages/platforms/claude-code/vitest.config.ts @@ -0,0 +1,22 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +/** Claude Code 单测让公开主包与私有 Core 共享同一源码品牌实例。 */ +export default defineConfig({ + resolve: { + alias: [ + { + find: /^@tokenroll\/acplugin\/sdk$/, + replacement: fileURLToPath(new URL('../../acplugin/src/sdk.ts', import.meta.url)), + }, + { + find: /^@tokenroll\/acplugin$/, + replacement: fileURLToPath(new URL('../../acplugin/src/index.ts', import.meta.url)), + }, + { + find: /^@acplugin\/core$/, + replacement: fileURLToPath(new URL('../../core/src/index.ts', import.meta.url)), + }, + ], + }, +}); diff --git a/packages/platforms/codex/CHANGELOG.md b/packages/platforms/codex/CHANGELOG.md new file mode 100644 index 0000000..8144da1 --- /dev/null +++ b/packages/platforms/codex/CHANGELOG.md @@ -0,0 +1,26 @@ +# @tokenroll/acplugin-platform-codex + +## 0.0.4-beta + +### Major Changes + +- Add opaque, subject-bound Platform Component Contributions to the trusted Integration SDK. Core now transports strict JSON payloads and records scoped contributor provenance in BuildReport schema version 3 without acquiring Platform-specific Agent or target-format knowledge. + + Claude Code, Cursor, and OpenCode expose and render their own native Agent contribution payloads during Platform finalization. Codex, Antigravity, and Pi explicitly reject non-empty private component contributions rather than silently dropping them or generating fallback Skills. + + Harden `AssetService.fromBytes()` to accept only exact data-object inputs, exact generated-origin fields, and `string | Uint8Array` bytes so third-party Integrations cannot rely on accessor, hidden-field, or array-like coercion. + +### Patch Changes + +- Updated dependencies + - @tokenroll/acplugin@0.0.3-beta + +## 0.0.3-beta + +### Major Changes + +- 889da32: Rewrite the Codex Platform around the Package API and make `-` the sole generated Skill identity for Commands. Validate the complete Skill namespace before Asset creation, inherit Core Runtime and validated primary Assets, and remove the obsolete generated-ID strategy option. + +### Patch Changes + +- Updated peer dependency on `@tokenroll/acplugin` to `^0.0.2-beta`. diff --git a/packages/platforms/codex/LICENSE b/packages/platforms/codex/LICENSE new file mode 100644 index 0000000..9113de7 --- /dev/null +++ b/packages/platforms/codex/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 TokenRollAI + +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/packages/platforms/codex/README.md b/packages/platforms/codex/README.md new file mode 100644 index 0000000..3bc32c6 --- /dev/null +++ b/packages/platforms/codex/README.md @@ -0,0 +1,32 @@ +# @tokenroll/acplugin-platform-codex + +Codex Platform package for `@tokenroll/acplugin`. + +```bash +pnpm add -D @tokenroll/acplugin @tokenroll/acplugin-platform-codex +``` + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import codex from '@tokenroll/acplugin-platform-codex'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [codex()], +}); +``` + +Commands become explicit Codex Skills named `-`. +For Plugin `my-plugin`, Command `bootstrap` is delivered at +`skills/my-plugin-bootstrap/SKILL.md`. Arbitrary templates and per-Command +overrides are intentionally not supported. + +The package also exports the named `codex` factory, its option types, `PLATFORM_ID`, and `PLATFORM_API_VERSION`. + +Codex does not currently expose a Platform Component Contribution payload. A Contributor that submits a non-empty private component contribution for Codex fails during Package finalization; it is never converted to an `agent-*` Skill. + +## License + +MIT diff --git a/packages/platforms/codex/package.json b/packages/platforms/codex/package.json new file mode 100644 index 0000000..6107dab --- /dev/null +++ b/packages/platforms/codex/package.json @@ -0,0 +1,34 @@ +{ + "name": "@tokenroll/acplugin-platform-codex", + "version": "0.0.4-beta", + "description": "Codex Platform integration for acplugin.", + "type": "module", + "license": "MIT", + "homepage": "https://github.com/TokenRollAI/acplugin#codex-platform", + "repository": { "type": "git", "url": "git+https://github.com/TokenRollAI/acplugin.git", "directory": "packages/platforms/codex" }, + "bugs": { "url": "https://github.com/TokenRollAI/acplugin/issues" }, + "sideEffects": false, + "engines": { "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, + "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" } }, + "files": ["dist", "README.md", "LICENSE"], + "publishConfig": { "access": "public" }, + "scripts": { + "build": "tsdown", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc -p tsconfig.json" + }, + "peerDependencies": { "@tokenroll/acplugin": "workspace:^" }, + "dependencies": { + "image-size": "^2.0.2", + "saxes": "^6.0.0", + "yaml": "^2.9.0" + }, + "devDependencies": { + "@acplugin/core": "workspace:*", + "@tokenroll/acplugin": "workspace:^", + "@types/node": "catalog:", + "tsdown": "catalog:", + "@typescript/native": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/platforms/codex/src/index.ts b/packages/platforms/codex/src/index.ts new file mode 100644 index 0000000..7d8c6dc --- /dev/null +++ b/packages/platforms/codex/src/index.ts @@ -0,0 +1,106 @@ +import { + definePlatform, + type AcpluginPlatform, + type JsonObject, +} from '@tokenroll/acplugin/sdk'; +import { + createCodexComponents, + validateCodexComponent, + validateGeneratedSkillIds, +} from './package/components.js'; +import { + createMarketplaceAssets, + createPluginDocument, + validatePlatformOptions, +} from './package/manifest.js'; +import type { CodexInterfaceOptions, CodexMarketplaceOptions, CodexPlatformOptions } from './types.js'; +import { validateCodexPackage } from './package/validation/index.js'; + +export type { + CodexCategory, + CodexInterfaceOptions, + CodexMarketplaceInstallation, + CodexMarketplaceOptions, + CodexMarketplacePolicyOptions, + CodexPlatformOptions, +} from './types.js'; + +/** Codex Platform 的稳定开放 ID。 */ +export const PLATFORM_ID = 'codex' as const; + +/** Codex Platform 实现的 Core API 版本。 */ +export const PLATFORM_API_VERSION = '1' as const; + +/** 创建只通过 Package API 交付 Codex Plugin 的 Platform。 */ +export function codex(options: CodexPlatformOptions = {}): AcpluginPlatform { + validatePlatformOptions(options); + /** strict 由 Core 解释,其余选项复制、深冻后提供给每个 Session。 */ + const { strict, ...platformOptions } = options; + return definePlatform({ + id: PLATFORM_ID, + apiVersion: PLATFORM_API_VERSION, + deliveryType: 'plugin', + capabilities: { nodeRuntime: { target: 'node20', format: 'esm', root: 'plugin' } }, + ...(strict === undefined ? {} : { strict }), + options: platformOptions as unknown as JsonObject, + /** 每轮构建只读取 Core 已复制的 sessionOptions。 */ + createSession({ options: sessionOptions }) { + return { + validateComponent: validateCodexComponent, + /** base Package 在生成任何 Asset 前检查最终共享 Skill namespace。 */ + async createPackage({ project, assets, diagnostics }) { + /** idsValid 防止 collision 诊断后继续签发有歧义的 bytes。 */ + const idsValid = validateGeneratedSkillIds(project, diagnostics); + /** manifest 可独立报告空 Skill 项目错误。 */ + const manifest = createPluginDocument({ project, options: sessionOptions, diagnostics }); + /** components 只在最终命名空间无冲突时构建。 */ + const components = idsValid + ? await createCodexComponents(project, assets) + : { assets: Object.freeze([]), compatibility: Object.freeze([]) }; + return { + documents: [manifest.document], + assets: components.assets, + compatibility: components.compatibility, + metadata: manifest.metadata, + }; + }, + /** + * Codex 尚未拥有 Platform Component 的原生交付契约。 + * + * 不能静默丢弃 opaque payload,也不能把它伪装成 `agent-*` Skill;两者都会 + * 让 Extension 对实际交付能力得到错误结论。等 Codex 有经过验证的本地表达 + * 时,由本包定义 payload union 和 finalization renderer。 + */ + finalizePackage: ({ package: mergedPackage, diagnostics }) => { + if (mergedPackage.components.length > 0) { + diagnostics.report({ + code: 'CODEX_COMPONENT_CONTRIBUTION_UNSUPPORTED', + severity: 'error', + message: 'Codex does not support Platform Component contributions.', + }); + } + return { id: 'plugin', type: 'plugin' }; + }, + validatePackage: validateCodexPackage, + /** 可选 Marketplace 只继承已验证 primary 的真实 AssetRef。 */ + async createDistributions(context) { + /** marketplace 和 interface 都来自同一个 session options snapshot。 */ + const marketplace = sessionOptions.marketplace as CodexMarketplaceOptions | undefined; + if (marketplace === undefined) + return Object.freeze([]); + return Object.freeze([{ + id: 'marketplace', + type: 'marketplace' as const, + assets: await createMarketplaceAssets( + context, + marketplace, + sessionOptions.interface as CodexInterfaceOptions | undefined, + ), + }]); + }, + }; + }, + }); +} + +export default codex; diff --git a/packages/platforms/codex/src/package/components.ts b/packages/platforms/codex/src/package/components.ts new file mode 100644 index 0000000..c276a47 --- /dev/null +++ b/packages/platforms/codex/src/package/components.ts @@ -0,0 +1,320 @@ +import { + markdownWithFrontmatter, + stableYaml, + type AgentComponent, + type AssetService, + type CanonicalProject, + type CommandComponent, + type CompatibilityInput, + type DiagnosticService, + type PackageAssetInput, + type PlatformComponentValidationContext, + type SkillComponent, +} from '@tokenroll/acplugin/sdk'; +import { CODEX_BRAND_COLOR_PATTERN, CODEX_SKILL_PRODUCTS } from './protocol.js'; + +/** Codex Skill `agents/openai.yaml` 允许配置的 Component 专属字段。 */ +const COMPONENT_FIELDS = new Set([ + 'displayName', 'shortDescription', 'iconSmall', 'iconLarge', 'brandColor', 'defaultPrompt', 'products', +]); + +/** Codex Skill 元数据允许声明的产品范围。 */ +const PRODUCTS = new Set(CODEX_SKILL_PRODUCTS); + +/** 三类 canonical Component 的联合视图。 */ +type Component = CommandComponent | SkillComponent | AgentComponent; + +/** 把未知 JSON 字段收窄为便于逐项验证的对象。 */ +type UnknownFields = Readonly>; + +/** 生成后的 Codex Skill ID 与规范来源。 */ +interface GeneratedSkillIdentity { + readonly id: string; + readonly subject: string; +} + +/** Codex Skill 的 `agents/openai.yaml` 结构。 */ +interface OpenAiSkillMetadata { + readonly interface: { + readonly display_name: string; + readonly short_description: string; + readonly icon_small?: string; + readonly icon_large?: string; + readonly brand_color?: string; + readonly default_prompt?: string; + }; + readonly policy?: { + readonly products?: readonly ('CHAT' | 'CODEX')[]; + readonly allow_implicit_invocation?: false; + }; +} + +/** Codex base Package 的 Component 转换结果。 */ +export interface CodexComponentPackage { + readonly assets: readonly PackageAssetInput[]; + readonly compatibility: readonly CompatibilityInput[]; +} + +/** @returns 值是否为非空字符串。 */ +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +/** @returns 值是否为不含重复项的非空字符串数组。 */ +function isUniqueStringArray(value: unknown): value is readonly string[] { + return Array.isArray(value) && value.every(isNonEmptyString) && new Set(value).size === value.length; +} + +/** @returns Skill 内部资源路径是否安全。 */ +function isSafeSkillPath(value: string): boolean { + if (!value.startsWith('./') || value.includes('\\') || value.includes('\0')) + return false; + /** relative 是去掉协议前缀后用于拒绝父目录和空路径的片段。 */ + const relative = value.slice(2); + return relative.length > 0 && relative !== '..' && !relative.startsWith('../') && !relative.split('/').includes('..'); +} + +/** 提交带 canonical Frontmatter 路径的 Codex 字段错误。 */ +function fieldError(context: PlatformComponentValidationContext, field: string, message: string): void { + context.diagnostics.report({ + code: 'CODEX_COMPONENT_FIELD_INVALID', severity: 'error', message, + fieldPath: ['platforms', 'codex', field], + }); +} + +/** 校验 Component 的 Codex Skill 展示字段,不允许 raw schema 逃逸。 */ +export function validateCodexComponent(context: PlatformComponentValidationContext): void { + /** fields 是 Scanner 已复制冻结的 Codex namespace。 */ + const fields: UnknownFields = context.component.platforms.codex ?? {}; + for (const field of Object.keys(fields)) { + if (!COMPONENT_FIELDS.has(field)) { + context.diagnostics.report({ + code: 'CODEX_COMPONENT_FIELD_UNKNOWN', severity: 'error', + message: `Unknown Codex ${context.component.kind} field "${field}".`, + fieldPath: ['platforms', 'codex', field], + }); + } + } + for (const field of ['displayName', 'shortDescription', 'defaultPrompt']) { + if (fields[field] !== undefined && !isNonEmptyString(fields[field])) + fieldError(context, field, `${field} must be a non-empty string.`); + } + for (const field of ['iconSmall', 'iconLarge']) { + if (fields[field] !== undefined && (!isNonEmptyString(fields[field]) || !isSafeSkillPath(fields[field]))) + fieldError(context, field, `${field} must start with ./ and stay inside the generated Skill root.`); + } + if (fields.brandColor !== undefined + && (!isNonEmptyString(fields.brandColor) || !CODEX_BRAND_COLOR_PATTERN.test(fields.brandColor))) { + fieldError(context, 'brandColor', 'brandColor must be a six-digit hexadecimal color.'); + } + if (fields.products !== undefined + && (!isUniqueStringArray(fields.products) || fields.products.some(product => !PRODUCTS.has(product)))) { + fieldError(context, 'products', 'products must contain CHAT, CODEX, or both without duplicates.'); + } +} + +/** @returns 当前 Component 已验证的 Codex namespace。 */ +function codexFields(component: Component): UnknownFields { + return component.platforms.codex ?? {}; +} + +/** @returns canonical Command 的默认 plugin-prefixed Codex Skill ID。 */ +export function commandSkillId(project: CanonicalProject, commandId: string): string { + return `${project.metadata.name}-${commandId}`; +} + +/** @returns 全部 canonical Component 最终占用的 Codex Skill identity。 */ +function generatedSkillIdentities(project: CanonicalProject): readonly GeneratedSkillIdentity[] { + return Object.freeze([ + ...project.skills.map(skill => Object.freeze({ id: skill.id, subject: `skill:${skill.id}` })), + ...project.commands.map(command => Object.freeze({ + id: commandSkillId(project, command.id), subject: `command:${command.id}`, + })), + ...project.agents.map(agent => Object.freeze({ id: `agent-${agent.id}`, subject: `agent:${agent.id}` })), + ]); +} + +/** 在任何 Asset 签发前拒绝 generated Skill ID 的 exact/case/NFC 冲突。 */ +export function validateGeneratedSkillIds(project: CanonicalProject, diagnostics: DiagnosticService): boolean { + /** owners 使用最严格目标文件系统的 NFC/case-fold key。 */ + const owners = new Map(); + /** valid 允许调用方在任何 bytes Asset 生成前中止转换。 */ + let valid = true; + for (const identity of generatedSkillIdentities(project)) { + /** key 不依赖 locale,canonical ID 本身只允许 ASCII lowercase kebab-case。 */ + const key = identity.id.normalize('NFC').toLowerCase(); + /** owner 是先占用相同最终 ID 的规范来源。 */ + const owner = owners.get(key); + if (owner !== undefined) { + valid = false; + diagnostics.report({ + code: 'CODEX_GENERATED_SKILL_ID_COLLISION', severity: 'error', + message: `${owner.subject} and ${identity.subject} both generate Codex Skill ID "${identity.id}".`, + hint: 'Rename one canonical Component so every native and generated Skill ID is unique.', + }); + } else { + owners.set(key, identity); + } + } + return valid; +} + +/** @returns 当前 Component 的完整 Codex Skill interface/policy,或无需生成时返回 undefined。 */ +function skillMetadata( + component: Component, + generatedId: string, + allowImplicitInvocation: boolean, +): OpenAiSkillMetadata | undefined { + /** fields 已经通过 validateComponent 检查。 */ + const fields = codexFields(component); + if (allowImplicitInvocation && Object.keys(fields).length === 0) + return undefined; + /** policy 仅在产品范围或隐式调用限制实际存在时生成。 */ + const policy = fields.products !== undefined || !allowImplicitInvocation + ? { + ...(fields.products === undefined ? {} : { products: fields.products as readonly ('CHAT' | 'CODEX')[] }), + ...(allowImplicitInvocation ? {} : { allow_implicit_invocation: false as const }), + } + : undefined; + return { + interface: { + display_name: fields.displayName as string | undefined ?? generatedId, + short_description: fields.shortDescription as string | undefined ?? component.description, + ...(fields.iconSmall === undefined ? {} : { icon_small: fields.iconSmall as string }), + ...(fields.iconLarge === undefined ? {} : { icon_large: fields.iconLarge as string }), + ...(fields.brandColor === undefined ? {} : { brand_color: fields.brandColor as string }), + ...(fields.defaultPrompt === undefined ? {} : { default_prompt: fields.defaultPrompt as string }), + }, + ...(policy === undefined ? {} : { policy }), + }; +} + +/** 如有需要,为一个 Skill 创建相邻 `agents/openai.yaml` Asset。 */ +async function appendSkillMetadata( + output: PackageAssetInput[], + assets: AssetService, + component: Component, + generatedId: string, + allowImplicitInvocation: boolean, +): Promise { + /** metadata 遵守 Codex interface 必填字段和 invocation policy。 */ + const metadata = skillMetadata(component, generatedId, allowImplicitInvocation); + if (metadata === undefined) + return; + /** asset 由当前 Platform owner 签发并携带精确 Component provenance。 */ + const asset = await assets.fromBytes({ + bytes: `${stableYaml(metadata)}\n`, + origin: { operation: 'skill-metadata', subjects: [`${component.kind}:${component.id}`] }, + }); + output.push(Object.freeze({ path: `skills/${generatedId}/agents/openai.yaml`, asset })); +} + +/** 把 Commands、Skills、Agents 转为 Codex Skills 并返回完整兼容性。 */ +export async function createCodexComponents( + project: CanonicalProject, + assets: AssetService, +): Promise { + /** output 保存 Platform-owned bytes 和被 Core 授权的 Skill auxiliary refs。 */ + const output: PackageAssetInput[] = []; + /** compatibility 精确覆盖每个 canonical Component 及实际附加语义。 */ + const compatibility: CompatibilityInput[] = []; + for (const skill of project.skills) { + /** manifest 是原生 Skill 主文档。 */ + const manifest = await assets.fromBytes({ + bytes: markdownWithFrontmatter({ name: skill.id, description: skill.description }, skill.body), + origin: { operation: 'component-skill', subjects: [`skill:${skill.id}`] }, + }); + output.push(Object.freeze({ path: `skills/${skill.id}/SKILL.md`, asset: manifest })); + await appendSkillMetadata(output, assets, skill, skill.id, skill.invocation.model); + for (const auxiliary of skill.auxiliaryFiles) + output.push(Object.freeze({ path: `skills/${skill.id}/${auxiliary.path}`, asset: auxiliary.asset })); + compatibility.push(Object.freeze({ + subject: `skill:${skill.id}`, capability: 'component', level: 'native', + reason: 'Codex supports Plugin Skills natively.', + })); + if (!skill.invocation.user) { + compatibility.push(Object.freeze({ + subject: `skill:${skill.id}`, capability: 'invocation.user', level: 'degraded', + transformation: 'explicit-invocation-remains', + reason: 'Codex Skill metadata cannot disable explicit user invocation.', + })); + } + } + + for (const command of project.commands) { + /** id 默认且始终包含 Plugin name,避免跨 Plugin generated Skill 冲突。 */ + const id = commandSkillId(project, command.id); + /** manifest 将 Command 显式调用语义转换为 Skill 指引。 */ + const manifest = await assets.fromBytes({ + bytes: markdownWithFrontmatter({ name: id, description: command.description }, + command.body.replaceAll('{{arguments}}', 'the arguments supplied with this explicit invocation')), + origin: { operation: 'component-command', subjects: [`command:${command.id}`] }, + }); + output.push(Object.freeze({ path: `skills/${id}/SKILL.md`, asset: manifest })); + await appendSkillMetadata(output, assets, command, id, false); + compatibility.push(Object.freeze({ + subject: `command:${command.id}`, capability: 'component', level: 'transform', + transformation: `explicit-skill:${id}`, + reason: 'Codex represents Commands as explicitly invoked Skills.', + })); + if (command.body.includes('{{arguments}}')) { + compatibility.push(Object.freeze({ + subject: `command:${command.id}`, capability: 'arguments', level: 'transform', + transformation: 'explicit-invocation-guidance', + reason: 'Codex Skills receive arguments through the invoking prompt rather than a Command placeholder.', + })); + } + if (command.argumentHint !== undefined) { + compatibility.push(Object.freeze({ + subject: `command:${command.id}`, capability: 'argument-hint', level: 'degraded', + transformation: 'argument-hint-omitted', + reason: 'Codex Skills do not expose the Command argument hint field.', + })); + } + } + + for (const agent of project.agents) { + /** id 使用固定 Agent 前缀避免与 native Skills 占用同一命名空间。 */ + const id = `agent-${agent.id}`; + /** guidance 明确保留不可强制执行的模型和 capability 作者意图。 */ + const guidance = [ + agent.body, + '', + `Intended model class: ${agent.model}.`, + `Intended capabilities: ${agent.capabilities.join(', ') || 'none declared'}.`, + 'When delegation is available, use a focused subagent with this role. These settings are guidance, not enforced registration.', + ].join('\n'); + /** manifest 是 guidance-only fallback Skill。 */ + const manifest = await assets.fromBytes({ + bytes: markdownWithFrontmatter({ name: id, description: agent.description }, guidance), + origin: { operation: 'component-agent', subjects: [`agent:${agent.id}`] }, + }); + output.push(Object.freeze({ path: `skills/${id}/SKILL.md`, asset: manifest })); + await appendSkillMetadata(output, assets, agent, id, true); + compatibility.push( + Object.freeze({ + subject: `agent:${agent.id}`, capability: 'component', level: 'degraded', + transformation: `guidance-skill:${id}`, + reason: 'Codex installable Plugins cannot register custom Agents.', + }), + Object.freeze({ + subject: `agent:${agent.id}`, capability: 'agent.model', level: 'degraded', + transformation: 'model-guidance', + reason: 'A fallback Skill cannot enforce an Agent model selection.', + }), + ); + if (agent.capabilities.length > 0) { + compatibility.push(Object.freeze({ + subject: `agent:${agent.id}`, capability: 'agent.capabilities', level: 'degraded', + transformation: 'capability-guidance', + reason: 'A fallback Skill cannot enforce an Agent tool capability boundary.', + })); + } + } + return Object.freeze({ assets: Object.freeze(output), compatibility: Object.freeze(compatibility) }); +} + +/** @returns 工程是否至少生成一个 Codex Skill。 */ +export function hasGeneratedSkills(project: CanonicalProject): boolean { + return project.skills.length + project.commands.length + project.agents.length > 0; +} diff --git a/packages/platforms/codex/src/package/manifest.ts b/packages/platforms/codex/src/package/manifest.ts new file mode 100644 index 0000000..8012f94 --- /dev/null +++ b/packages/platforms/codex/src/package/manifest.ts @@ -0,0 +1,282 @@ +import { + stableJson, + type CanonicalProject, + type DistributionAssetInput, + type DistributionContext, + type JsonObject, + type MetadataDispositionInput, + type PackageDocumentInput, + type PluginMetadata, +} from '@tokenroll/acplugin/sdk'; +import { hasGeneratedSkills } from './components.js'; +import { + CODEX_CATEGORIES, + CODEX_INTERFACE_OPTION_FIELDS, + CODEX_MARKETPLACE_INSTALLATIONS, + codexInterfaceFieldIssue, +} from './protocol.js'; +import type { + CodexCategory, + CodexInterfaceOptions, + CodexMarketplaceManifest, + CodexMarketplaceOptions, + CodexMarketplacePlugin, + CodexPlatformOptions, + CodexPluginInterface, + CodexPluginManifest, +} from '../types.js'; + +/** Codex Plugin 清单的稳定逻辑 Document ID。 */ +export const PLUGIN_MANIFEST_ID = 'plugin-manifest'; + +/** Codex Plugin 清单相对于安装根的官方固定路径。 */ +export const PLUGIN_MANIFEST_PATH = '.codex-plugin/plugin.json'; + +/** Codex Repo Marketplace 清单相对于 Distribution 根的官方固定路径。 */ +export const MARKETPLACE_MANIFEST_PATH = '.agents/plugins/marketplace.json'; + +/** Codex Marketplace 机器名称采用的保守 kebab-case 规则。 */ +const MARKETPLACE_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** Codex 官方插件目录当前接受的分类集合。 */ +const CATEGORIES = new Set(CODEX_CATEGORIES); + +/** Codex Marketplace 当前支持的安装策略集合。 */ +const INSTALLATION_POLICIES = new Set(CODEX_MARKETPLACE_INSTALLATIONS); + +/** 校验可选字符串字段。 */ +function assertOptionalString(value: unknown, field: string): void { + if (value !== undefined && (typeof value !== 'string' || value.trim().length === 0)) + throw new TypeError(`Codex ${field} must be a non-empty string.`); +} + +/** 拒绝配置对象中的未知字段。 */ +function rejectUnknownFields(value: object, allowed: ReadonlySet, field: string): void { + for (const key of Object.keys(value)) { + if (!allowed.has(key)) + throw new TypeError(`Unknown Codex ${field} option "${key}".`); + } +} + +/** 校验 Codex Plugin interface 平台选项。 */ +function validateInterfaceOptions(options: CodexInterfaceOptions | undefined): void { + if (options === undefined) + return; + rejectUnknownFields(options, new Set(CODEX_INTERFACE_OPTION_FIELDS), 'interface'); + for (const field of CODEX_INTERFACE_OPTION_FIELDS) { + /** value 是当前可选 interface 配置。 */ + const value = options[field]; + if (value === undefined) + continue; + /** issue 复用最终 validator 的纯协议规则。 */ + const issue = codexInterfaceFieldIssue(field, value); + if (issue !== undefined) + throw new TypeError(`Codex ${issue.message}`); + } +} + +/** 校验 Codex Marketplace 选项。 */ +function validateMarketplaceOptions(options: CodexMarketplaceOptions | undefined): void { + if (options === undefined) + return; + rejectUnknownFields(options, new Set(['name', 'displayName', 'category', 'policy']), 'marketplace'); + assertOptionalString(options.name, 'marketplace.name'); + assertOptionalString(options.displayName, 'marketplace.displayName'); + if (options.name !== undefined && !MARKETPLACE_NAME_PATTERN.test(options.name)) + throw new TypeError('Codex marketplace.name must use lowercase kebab-case.'); + if (options.category !== undefined && !CATEGORIES.has(options.category)) + throw new TypeError('Codex marketplace.category is not an official Plugin category.'); + if (options.policy !== undefined) { + rejectUnknownFields(options.policy, new Set(['installation']), 'marketplace.policy'); + if (options.policy.installation !== undefined && !INSTALLATION_POLICIES.has(options.policy.installation)) + throw new TypeError('Codex marketplace.policy.installation is not supported.'); + } +} + +/** 校验 Codex Platform 工厂公开配置。 */ +export function validatePlatformOptions(options: CodexPlatformOptions): void { + rejectUnknownFields(options, new Set(['strict', 'interface', 'marketplace']), 'Platform'); + if (options.strict !== undefined && typeof options.strict !== 'boolean') + throw new TypeError('Codex strict must be a boolean.'); + validateInterfaceOptions(options.interface); + validateMarketplaceOptions(options.marketplace); +} + +/** @returns 统一元数据与 Platform 选项组成的 Codex 安装界面。 */ +function pluginInterface( + metadata: PluginMetadata, + options: CodexInterfaceOptions | undefined, +): CodexPluginInterface | undefined { + if (options === undefined && metadata.displayName === undefined) + return undefined; + return { + displayName: metadata.displayName ?? metadata.name, + shortDescription: options?.shortDescription ?? metadata.description, + longDescription: options?.longDescription ?? metadata.description, + developerName: options?.developerName ?? metadata.author?.name ?? metadata.name, + ...(options?.category === undefined ? {} : { category: options.category }), + ...(options?.capabilities === undefined ? {} : { capabilities: options.capabilities }), + ...(options?.websiteURL ?? metadata.homepage) === undefined + ? {} + : { websiteURL: options?.websiteURL ?? metadata.homepage! }, + ...(options?.privacyPolicyURL === undefined ? {} : { privacyPolicyURL: options.privacyPolicyURL }), + ...(options?.termsOfServiceURL === undefined ? {} : { termsOfServiceURL: options.termsOfServiceURL }), + ...(options?.supportURL === undefined ? {} : { supportURL: options.supportURL }), + ...(options?.defaultPrompt === undefined ? {} : { defaultPrompt: options.defaultPrompt }), + ...(options?.brandColor === undefined ? {} : { brandColor: options.brandColor }), + ...(options?.brandColorDark === undefined ? {} : { brandColorDark: options.brandColorDark }), + ...(options?.composerIcon === undefined ? {} : { composerIcon: options.composerIcon }), + ...(options?.logo === undefined ? {} : { logo: options.logo }), + ...(options?.screenshots === undefined ? {} : { screenshots: options.screenshots }), + }; +} + +/** @returns 完整 Codex Plugin 清单。 */ +function pluginManifest( + project: CanonicalProject, + options: CodexInterfaceOptions | undefined, +): CodexPluginManifest { + /** metadata 已由 Core config resolver 完整验证。 */ + const metadata = project.metadata; + /** interfaceValue 只在有实际统一或专属展示字段时存在。 */ + const interfaceValue = pluginInterface(metadata, options); + return { + name: metadata.name, + version: metadata.version, + description: metadata.description, + ...(metadata.author === undefined ? {} : { author: metadata.author }), + ...(metadata.homepage === undefined ? {} : { homepage: metadata.homepage }), + ...(metadata.repository === undefined ? {} : { repository: metadata.repository }), + ...(metadata.license === undefined ? {} : { license: metadata.license }), + ...(metadata.keywords.length === 0 ? {} : { keywords: metadata.keywords }), + skills: './skills/', + ...(interfaceValue === undefined ? {} : { interface: interfaceValue }), + }; +} + +/** @returns 当前工程实际 metadata 的完整 disposition。 */ +function metadataDispositions(metadata: PluginMetadata): readonly MetadataDispositionInput[] { + /** outputs 为每个字段声明最终 Manifest 位置。 */ + const outputs: [string, string][] = [ + ['name', `${PLUGIN_MANIFEST_PATH}/name`], + ['version', `${PLUGIN_MANIFEST_PATH}/version`], + ['description', `${PLUGIN_MANIFEST_PATH}/description`], + ]; + for (const field of ['homepage', 'repository', 'license'] as const) { + if (metadata[field] !== undefined) + outputs.push([field, `${PLUGIN_MANIFEST_PATH}/${field}`]); + } + if (metadata.author !== undefined) { + outputs.push(['author.name', `${PLUGIN_MANIFEST_PATH}/author/name`]); + if (metadata.author.email !== undefined) + outputs.push(['author.email', `${PLUGIN_MANIFEST_PATH}/author/email`]); + if (metadata.author.url !== undefined) + outputs.push(['author.url', `${PLUGIN_MANIFEST_PATH}/author/url`]); + } + if (metadata.keywords.length > 0) + outputs.push(['keywords', `${PLUGIN_MANIFEST_PATH}/keywords`]); + if (metadata.displayName !== undefined) + outputs.push(['displayName', `${PLUGIN_MANIFEST_PATH}/interface/displayName`]); + return Object.freeze(outputs.map(([field, output]) => Object.freeze({ + field, disposition: 'emitted' as const, output, + reason: `Codex plugin.json supports ${field}.`, + }))); +} + +/** 创建由 Core codec 序列化、只开放 Hooks/MCP 的 Plugin Document。 */ +export function createPluginDocument(input: { + readonly project: CanonicalProject; + readonly options: Readonly; + readonly diagnostics: { readonly report: (input: { readonly code: string; readonly severity: 'error'; readonly message: string }) => void }; +}): { readonly document: PackageDocumentInput; readonly metadata: readonly MetadataDispositionInput[] } { + if (!hasGeneratedSkills(input.project)) { + input.diagnostics.report({ + code: 'CODEX_SKILL_REQUIRED', severity: 'error', + message: 'A Codex Plugin must contain at least one native or generated Skill.', + }); + } + /** interfaceOptions 来自 Platform session 深冻 JSON 副本。 */ + const interfaceOptions = input.options.interface as CodexInterfaceOptions | undefined; + /** document 是 Codex base Package 的唯一结构化清单。 */ + const document: PackageDocumentInput = Object.freeze({ + id: PLUGIN_MANIFEST_ID, + path: PLUGIN_MANIFEST_PATH, + format: 'json', + value: pluginManifest(input.project, interfaceOptions) as unknown as JsonObject, + extensionPoints: Object.freeze([ + Object.freeze(['hooks'] as const), + Object.freeze(['mcpServers'] as const), + ]), + }); + return Object.freeze({ document, metadata: metadataDispositions(input.project.metadata) }); +} + +/** @returns 一个 single-primary Marketplace Plugin 条目。 */ +function marketplacePlugin( + manifest: CodexPluginManifest, + options: CodexMarketplaceOptions, + interfaceOptions: CodexInterfaceOptions | undefined, +): CodexMarketplacePlugin { + return { + name: manifest.name, + source: { source: 'local', path: './' }, + policy: { + installation: options.policy?.installation ?? 'AVAILABLE', + authentication: 'ON_INSTALL', + }, + category: options.category ?? interfaceOptions?.category ?? 'Other', + }; +} + +/** 从已验证 primary 的真实 AssetRef 读取 Plugin 清单。 */ +async function readPrimaryManifest(context: DistributionContext): Promise { + /** manifestAsset 必须命中 Core codec 创建的固定路径。 */ + const manifestAsset = context.primary.assets.find(asset => asset.path === PLUGIN_MANIFEST_PATH); + if (manifestAsset === undefined) + throw new Error(`Codex primary Package is missing ${PLUGIN_MANIFEST_PATH}.`); + /** value 在读取必填字段前保持未知。 */ + const value: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode( + await context.assets.read(manifestAsset.asset), + )); + if (value === null || typeof value !== 'object' || Array.isArray(value)) + throw new Error('Codex primary Package has an invalid Plugin Manifest.'); + /** manifest 只在三个身份字段完成检查后进入 Marketplace。 */ + const manifest = value as Record; + if (typeof manifest.name !== 'string' || typeof manifest.version !== 'string' || typeof manifest.description !== 'string') + throw new Error('Codex primary Package has incomplete Plugin metadata.'); + return manifest as unknown as CodexPluginManifest; +} + +/** 从 validated primary 创建保留全部 AssetRef 身份的 Codex Marketplace。 */ +export async function createMarketplaceAssets( + context: DistributionContext, + options: CodexMarketplaceOptions, + interfaceOptions: CodexInterfaceOptions | undefined, +): Promise { + if (context.primary.assets.some(asset => asset.path === MARKETPLACE_MANIFEST_PATH)) { + context.diagnostics.report({ + code: 'CODEX_MARKETPLACE_PATH_CONFLICT', severity: 'error', + message: 'The primary Plugin already contains the reserved Marketplace manifest path.', + }); + return Object.freeze([]); + } + /** manifest 已通过 primary candidate validator。 */ + const manifest = await readPrimaryManifest(context); + /** marketplace 只表达当前 BuildSession 的单一 primary。 */ + const marketplace: CodexMarketplaceManifest = { + name: options.name ?? `${context.project.metadata.name}-marketplace`, + interface: { + displayName: options.displayName ?? `${context.project.metadata.displayName ?? context.project.metadata.name} Marketplace`, + }, + plugins: [marketplacePlugin(manifest, options, interfaceOptions)], + }; + /** marketplaceAsset 是本 Distribution callback 唯一新签发的 bytes。 */ + const marketplaceAsset = await context.assets.fromBytes({ + bytes: stableJson(marketplace as unknown as JsonObject), + origin: { operation: 'marketplace-manifest', subjects: ['distribution:marketplace'] }, + }); + return Object.freeze([ + ...context.primary.assets.map(asset => Object.freeze({ path: asset.path, asset: asset.asset })), + Object.freeze({ path: MARKETPLACE_MANIFEST_PATH, asset: marketplaceAsset }), + ]); +} diff --git a/packages/platforms/codex/src/package/protocol.ts b/packages/platforms/codex/src/package/protocol.ts new file mode 100644 index 0000000..c2722c3 --- /dev/null +++ b/packages/platforms/codex/src/package/protocol.ts @@ -0,0 +1,253 @@ +/** Codex 官方插件目录当前接受的分类清单。 */ +export const CODEX_CATEGORIES = [ + 'Productivity', + 'Creativity', + 'Developer Tools', + 'Business & Operations', + 'Data & Analytics', + 'Communication', + 'Education & Research', + 'Security', + 'Finance', + 'Healthcare', + 'Travel', + 'Entertainment', + 'Other', +] as const; + +/** Codex 官方插件目录分类的封闭联合类型。 */ +export type CodexCategory = typeof CODEX_CATEGORIES[number]; + +/** Codex Marketplace 当前接受的安装策略清单。 */ +export const CODEX_MARKETPLACE_INSTALLATIONS = [ + 'AVAILABLE', + 'INSTALLED_BY_DEFAULT', + 'NOT_AVAILABLE', +] as const; + +/** Codex Marketplace 安装策略的封闭联合类型。 */ +export type CodexMarketplaceInstallation = typeof CODEX_MARKETPLACE_INSTALLATIONS[number]; + +/** Codex Skill 元数据当前接受的产品范围。 */ +export const CODEX_SKILL_PRODUCTS = ['CHAT', 'CODEX'] as const; + +/** Plugin 与 Skill 品牌色共同使用的六位十六进制规则。 */ +export const CODEX_BRAND_COLOR_PATTERN: RegExp = /^#[\dA-Fa-f]{6}$/; + +/** Codex Plugin `interface` 当前允许的全部官方字段。 */ +export const CODEX_INTERFACE_FIELDS = [ + 'displayName', 'shortDescription', 'longDescription', 'developerName', 'category', 'capabilities', + 'websiteURL', 'privacyPolicyURL', 'termsOfServiceURL', 'supportURL', 'defaultPrompt', 'brandColor', + 'brandColorDark', 'composerIcon', 'logo', 'screenshots', +] as const; + +/** Codex interface 字段名称的封闭联合类型。 */ +export type CodexInterfaceField = typeof CODEX_INTERFACE_FIELDS[number]; + +/** Platform 工厂允许配置的 interface 字段联合类型。 */ +export type CodexInterfaceOptionField = Exclude; + +/** Platform 工厂可配置、但不重复顶层 displayName 的 interface 字段。 */ +export const CODEX_INTERFACE_OPTION_FIELDS: readonly CodexInterfaceOptionField[] + = CODEX_INTERFACE_FIELDS.filter((field): field is CodexInterfaceOptionField => field !== 'displayName'); + +/** Codex Plugin interface 一旦存在就必须提供的发布展示字段。 */ +export const CODEX_INTERFACE_REQUIRED_FIELDS = [ + 'displayName', 'shortDescription', 'longDescription', 'developerName', +] as const; + +/** 共享 interface 纯校验返回的稳定问题。 */ +export interface CodexInterfaceFieldIssue { + readonly code: string; + readonly message: string; +} + +/** + * 判断插件根资源引用是否为安全的 `./` 相对路径。 + * + * @param value 待验证的 Manifest 资源路径。 + * @returns 路径不会逃逸 Plugin 根时返回 true。 + */ +export function isSafeCodexPluginPath(value: string): boolean { + if (!value.startsWith('./') || value.includes('\\') || value.includes('\0')) + return false; + /** 去掉协议前缀后用于拒绝父目录和空路径的片段。 */ + const relative = value.slice(2); + return relative.length > 0 + && relative !== '..' + && !relative.startsWith('../') + && !relative.split('/').includes('..'); +} + +/** + * 判断字符串是否为不含凭据的 HTTPS URL。 + * + * @param value 待验证的发布或作者链接。 + * @returns URL 可由官方目录安全接受时返回 true。 + */ +export function isCodexHttpsUrl(value: string): boolean { + try { + /** 标准 URL 解析器同时拒绝伪造协议、缺失 host 和嵌入凭据。 */ + const url = new URL(value); + return url.protocol === 'https:' + && url.hostname.length > 0 + && url.username === '' + && url.password === ''; + } catch { + return false; + } +} + +/** + * 对一个已知 Codex interface 字段执行共享纯值校验。 + * + * @param field 当前官方字段名称。 + * @param value Factory 输入或最终 Manifest 中的候选值。 + * @returns 值不符合官方协议时返回稳定问题,否则返回 undefined。 + */ +export function codexInterfaceFieldIssue( + field: CodexInterfaceField, + value: unknown, +): CodexInterfaceFieldIssue | undefined { + if (field === 'capabilities') { + if (!Array.isArray(value) + || value.length > 20 + || value.some(capability => typeof capability !== 'string' + || capability.trim().length === 0 + || capability.length > 120)) { + return { + code: 'CODEX_INTERFACE_CAPABILITIES_INVALID', + message: 'interface.capabilities must contain at most 20 non-empty strings of 120 characters or fewer.', + }; + } + return undefined; + } + if (field === 'screenshots') { + if (!Array.isArray(value) + || value.length === 0 + || value.some(item => typeof item !== 'string' || !isSafeCodexPluginPath(item))) { + return { + code: 'CODEX_INTERFACE_SCREENSHOTS_INVALID', + message: 'interface.screenshots must contain safe Plugin-root paths.', + }; + } + return undefined; + } + if (field === 'defaultPrompt') { + /** 单值与数组写法统一后的 starter prompt。 */ + const prompts = typeof value === 'string' ? [value] : value; + if (!Array.isArray(prompts) + || prompts.length === 0 + || prompts.length > 3 + || prompts.some(prompt => typeof prompt !== 'string' + || prompt.trim().length === 0 + || prompt.length > 512 + || /[\r\n]/u.test(prompt))) { + return { + code: 'CODEX_INTERFACE_PROMPT_INVALID', + message: 'interface.defaultPrompt must contain one to three non-empty single-line prompts.', + }; + } + return undefined; + } + if (typeof value !== 'string' || value.trim().length === 0) { + return { + code: 'CODEX_INTERFACE_FIELD_INVALID', + message: `interface.${field} must be a non-empty string.`, + }; + } + if (field === 'displayName' && value.length > 80) + return { code: 'CODEX_INTERFACE_DISPLAY_NAME_INVALID', message: 'interface.displayName must contain at most 80 characters.' }; + if (field === 'shortDescription' && (value.length > 240 || /[\r\n]/u.test(value))) { + return { + code: 'CODEX_INTERFACE_SHORT_DESCRIPTION_INVALID', + message: 'interface.shortDescription must fit on one line and contain at most 240 characters.', + }; + } + if (field === 'longDescription' && value.length > 4_000) + return { code: 'CODEX_INTERFACE_LONG_DESCRIPTION_INVALID', message: 'interface.longDescription must contain at most 4000 characters.' }; + if (field === 'developerName' && value.length > 120) + return { code: 'CODEX_INTERFACE_DEVELOPER_NAME_INVALID', message: 'interface.developerName must contain at most 120 characters.' }; + if (field === 'category' && !(CODEX_CATEGORIES as readonly string[]).includes(value)) + return { code: 'CODEX_INTERFACE_CATEGORY_INVALID', message: 'interface.category must be an official Plugin category.' }; + if (['websiteURL', 'privacyPolicyURL', 'termsOfServiceURL', 'supportURL'].includes(field) + && (!isCodexHttpsUrl(value) || value.length > 2_048)) { + return { code: 'CODEX_INTERFACE_URL_INVALID', message: `interface.${field} must be an HTTPS URL without credentials.` }; + } + if ((field === 'brandColor' || field === 'brandColorDark') && !CODEX_BRAND_COLOR_PATTERN.test(value)) + return { code: 'CODEX_INTERFACE_COLOR_INVALID', message: `interface.${field} must be a six-digit hexadecimal color.` }; + if ((field === 'composerIcon' || field === 'logo') && !isSafeCodexPluginPath(value)) + return { code: 'CODEX_INTERFACE_ASSET_INVALID', message: `interface.${field} must be a safe Plugin-root path.` }; + return undefined; +} + +/** SVG 数值属性接受的无单位十进制与科学计数法。 */ +const SVG_NUMBER_PATTERN = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/; + +/** SVG 根元素解析后用于尺寸判断的结果。 */ +export interface CodexSvgDimensions { + readonly width: number; + readonly height: number; +} + +/** + * 严格解析 Codex 品牌 SVG 的 UTF-8 XML、根元素和无单位尺寸。 + * + * @param bytes 最终候选中的 SVG 原始字节。 + * @returns viewBox 或 width/height 表达的正数尺寸。 + * @throws XML、根元素或尺寸不符合公共目录协议时抛出错误。 + */ +export function parseCodexSvgDimensions(bytes: Uint8Array): CodexSvgDimensions { + /** fatal 解码确保无效 UTF-8 不会被替换字符静默修复。 */ + const source = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + /** 第一层 SVG 根元素的属性快照。 */ + let rootAttributes: Readonly> | undefined; + /** 严格文档模式会拒绝未闭合标签和多个根元素。 */ + const parser = new SaxesParser({ xmlns: true }); + parser.on('opentag', (tag) => { + if (rootAttributes !== undefined) + return; + if (tag.local !== 'svg') + throw new Error('SVG root element must be .'); + /** 只按 local name 保存根属性,避免 namespace 前缀影响标准属性。 */ + const attributes: Record = {}; + /** attribute 表示当前 SVG 根属性。 */ + for (const attribute of Object.values(tag.attributes)) + attributes[attribute.local] = attribute.value; + rootAttributes = attributes; + }); + parser.write(source).close(); + if (rootAttributes === undefined) + throw new Error('SVG root element must be .'); + /** 把无单位数值文本转换为有限 Number。 */ + const numeric = (value: string | undefined): number | undefined => { + if (value === undefined || !SVG_NUMBER_PATTERN.test(value.trim())) + return undefined; + /** 已通过严格语法检查的有限数值候选。 */ + const number = Number(value); + return Number.isFinite(number) ? number : undefined; + }; + /** viewBox 存在时优先使用其宽高,且不允许回退掩盖非法 viewBox。 */ + const viewBox = rootAttributes.viewBox; + /** 最终参与方形和范围校验的宽高。 */ + let width: number | undefined; + /** 与 width 同源且必须满足相同范围的最终高度。 */ + let height: number | undefined; + if (viewBox !== undefined) { + /** SVG viewBox 允许空白或逗号分隔的四个无单位数值。 */ + const values = viewBox.trim().split(/[\s,]+/u).map(value => numeric(value)); + if (values.length !== 4 || values.some(value => value === undefined)) + throw new Error('SVG viewBox must contain four numeric values without units.'); + width = values[2]; + height = values[3]; + } else { + width = numeric(rootAttributes.width); + height = numeric(rootAttributes.height); + if (width === undefined || height === undefined) + throw new Error('SVG width and height must be numeric values without units.'); + } + if (width === undefined || height === undefined || width <= 0 || height <= 0) + throw new Error('SVG width and height must be positive finite numbers.'); + return { width, height }; +} +import { SaxesParser } from 'saxes'; diff --git a/packages/platforms/codex/src/package/validation/assets.ts b/packages/platforms/codex/src/package/validation/assets.ts new file mode 100644 index 0000000..d2c541f --- /dev/null +++ b/packages/platforms/codex/src/package/validation/assets.ts @@ -0,0 +1,146 @@ +/** Codex interface 与品牌 Asset validator。 */ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { JsonValue } from '@tokenroll/acplugin/sdk'; +import { imageSize } from 'image-size'; +import { + CODEX_INTERFACE_FIELDS, + CODEX_INTERFACE_REQUIRED_FIELDS, + codexInterfaceFieldIssue, + parseCodexSvgDimensions, +} from '../protocol.js'; +import { + isRecord, + isSafePluginReference, + referenceExists, + report, + validateReference, + type PlatformValidateContext, +} from './shared.js'; + +/** Codex Plugin `interface` 允许出现的当前官方字段。 */ +const INTERFACE_FIELDS = new Set(CODEX_INTERFACE_FIELDS); + +/** Codex 目录品牌图片支持的文件扩展名。 */ +const BRANDING_IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.svg']); + +/** Codex 目录品牌图片允许的最大字节数。 */ +const MAX_BRANDING_IMAGE_BYTES = 5 * 1024 * 1024; + +/** + * 校验已存在的 Codex 目录品牌图片格式、字节数和方形尺寸。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 + * @param reference 相对于 Plugin 根的图片路径。 + * @param field Manifest 中声明图片的字段。 + * @param fieldPath 精确诊断位置。 + */ +async function validateBrandingImage( + context: PlatformValidateContext, + pluginRoot: string, + reference: string, + field: string, + fieldPath: readonly (string | number)[], +): Promise { + if (!isSafePluginReference(reference)) + return; + /** Manifest 引用转换后的候选根内 Asset 路径。 */ + const assetPath = reference.slice(2); + /** 图片文件名的规范小写扩展名。 */ + const extension = path.posix.extname(assetPath).toLocaleLowerCase('en-US'); + if (!BRANDING_IMAGE_EXTENSIONS.has(extension)) { + report(context, 'CODEX_BRANDING_IMAGE_FORMAT_UNSUPPORTED', `${field} must use PNG, JPEG, WebP, or SVG.`, fieldPath); + return; + } + try { + /** 从 Core 已物化的候选根读取实际图片字节。 */ + const bytes = await fs.readFile(path.join(context.candidate.root, pluginRoot, assetPath)); + if (bytes.byteLength > MAX_BRANDING_IMAGE_BYTES) { + report(context, 'CODEX_BRANDING_IMAGE_TOO_LARGE', `${field} must not exceed 5 MiB.`, fieldPath); + return; + } + /** SVG 与 Raster 解析后统一参与方形和范围校验的尺寸。 */ + let dimensions: { readonly width?: number; readonly height?: number }; + if (extension === '.svg') { + dimensions = parseCodexSvgDimensions(bytes); + } else { + /** Raster 继续使用二进制格式探测与安全解码。 */ + const raster = imageSize(bytes); + /** `.jpeg` 与 image-size 返回的 `jpg` 使用同一检测格式。 */ + const expectedType = extension === '.jpeg' ? 'jpg' : extension.slice(1); + if (raster.type !== expectedType) { + report(context, 'CODEX_BRANDING_IMAGE_CONTENT_MISMATCH', `${field} extension must match the detected image format.`, fieldPath); + } + dimensions = raster; + } + if (dimensions.width === undefined || dimensions.height === undefined + || dimensions.width !== dimensions.height + || dimensions.width < 48 + || dimensions.width > 4_096) { + report(context, 'CODEX_BRANDING_IMAGE_DIMENSIONS_INVALID', `${field} must be a square image between 48 and 4096 pixels.`, fieldPath); + } + } catch { + report(context, 'CODEX_BRANDING_IMAGE_DECODE_FAILED', `${field} must reference a readable, decodable image.`, fieldPath); + } +} + +/** + * 校验 Codex Plugin 安装界面字段和资源引用。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param assets 当前 Package 的 Asset 路径集合。 + * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 + * @param value Manifest 的 interface 候选。 + */ +export async function validateInterface( + context: PlatformValidateContext, + assets: ReadonlySet, + pluginRoot: string, + value: JsonValue, +): Promise { + if (!isRecord(value)) { + report(context, 'CODEX_INTERFACE_OBJECT_REQUIRED', 'interface must be a JSON object.', ['interface']); + return; + } + for (const field of Object.keys(value)) { + if (!INTERFACE_FIELDS.has(field)) + report(context, 'CODEX_INTERFACE_FIELD_UNKNOWN', `Unknown Codex interface field "${field}".`, ['interface', field]); + } + /** 已报告纯值问题的字段不再进入资源存在性校验。 */ + const invalidFields = new Set(); + for (const field of CODEX_INTERFACE_FIELDS) { + /** 当前最终 interface 字段候选。 */ + const candidate = value[field]; + /** 当前字段是否属于 interface 存在时的四个必填展示字段。 */ + const required = (CODEX_INTERFACE_REQUIRED_FIELDS as readonly string[]).includes(field); + /** 必填字段缺失、类型错误或空白时只报告必填问题。 */ + const requiredInvalid = required && (typeof candidate !== 'string' || candidate.trim().length === 0); + if (candidate === undefined || requiredInvalid) { + if (required) { + report(context, 'CODEX_INTERFACE_FIELD_REQUIRED', `interface.${field} must be a non-empty string.`, ['interface', field]); + invalidFields.add(field); + } + continue; + } + /** 共享纯规则返回的第一个稳定问题。 */ + const issue = codexInterfaceFieldIssue(field, candidate); + if (issue !== undefined) { + report(context, issue.code, issue.message, ['interface', field]); + invalidFields.add(field); + } + } + for (const field of ['composerIcon', 'logo'] as const) { + /** 当前图片路径候选。 */ + const candidate = value[field]; + if (typeof candidate === 'string' && !invalidFields.has(field)) { + validateReference(context, assets, `interface.${field}`, candidate, ['interface', field]); + if (referenceExists(assets, candidate)) + await validateBrandingImage(context, pluginRoot, candidate, `interface.${field}`, ['interface', field]); + } + } + if (Array.isArray(value.screenshots) && !invalidFields.has('screenshots')) { + for (const [index, screenshot] of value.screenshots.entries()) + validateReference(context, assets, 'interface.screenshots', screenshot as string, ['interface', 'screenshots', index]); + } +} diff --git a/packages/platforms/codex/src/package/validation/hooks.ts b/packages/platforms/codex/src/package/validation/hooks.ts new file mode 100644 index 0000000..ed0f035 --- /dev/null +++ b/packages/platforms/codex/src/package/validation/hooks.ts @@ -0,0 +1,276 @@ +/** Codex Hook wire contract validator。 */ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { JsonValue } from '@tokenroll/acplugin/sdk'; +import { + isRecord, + isSafePluginReference, + referenceExists, + report, + validateReference, + type JsonRecord, + type PlatformValidateContext, +} from './shared.js'; + +/** Codex 当前公开并可以从 Plugin 生命周期配置触发的 Hook 事件。 */ +const HOOK_EVENTS = new Set([ + 'SessionStart', 'SessionEnd', 'UserPromptSubmit', 'PreToolUse', 'PermissionRequest', 'PostToolUse', + 'PreCompact', 'PostCompact', 'SubagentStart', 'SubagentStop', 'Stop', +]); + +/** Codex `hooks.json` 顶层允许出现的字段。 */ +const HOOK_CONFIG_FIELDS = new Set(['description', 'hooks']); + +/** 单个 Codex Hook matcher 分组允许出现的字段。 */ +const HOOK_GROUP_FIELDS = new Set(['matcher', 'hooks']); + +/** 当前可执行 Codex command Hook Handler 允许出现的字段。 */ +const HOOK_HANDLER_FIELDS = new Set([ + 'type', 'command', 'commandWindows', 'command_windows', 'timeout', 'statusMessage', + 'additionalContextLimit', 'async', +]); + +/** + * 校验 Codex Hook matcher 是可执行的正则字符串。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param value matcher 候选值。 + * @param fieldPath matcher 在最终 Hook 配置中的字段路径。 + */ +function validateHookMatcher( + context: PlatformValidateContext, + value: JsonValue, + fieldPath: readonly (string | number)[], +): void { + if (typeof value !== 'string') { + report(context, 'CODEX_HOOK_MATCHER_INVALID', 'Hook matcher must be a regular-expression string.', fieldPath); + return; + } + try { + /** 构造正则只用于验证 Codex 将要解析的表达式语法。 */ + const expression = new RegExp(value); + void expression; + } catch { + report(context, 'CODEX_HOOK_MATCHER_INVALID', 'Hook matcher must be a valid regular expression.', fieldPath); + } +} + +/** + * 校验 Codex command Hook Handler 的字段和平台限制。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param event 当前 Handler 所属事件。 + * @param value Handler 候选值。 + * @param fieldPath Handler 在最终 Hook 配置中的字段路径。 + */ +function validateHookHandler( + context: PlatformValidateContext, + event: string, + value: JsonValue, + fieldPath: readonly (string | number)[], +): void { + if (!isRecord(value)) { + report(context, 'CODEX_HOOK_HANDLER_INVALID', 'Hook handlers must be JSON objects.', fieldPath); + return; + } + if (value.type !== 'command') { + report(context, 'CODEX_HOOK_HANDLER_TYPE_INVALID', 'Codex currently executes only command Hook handlers.', [...fieldPath, 'type']); + return; + } + for (const field of Object.keys(value)) { + if (!HOOK_HANDLER_FIELDS.has(field)) + report(context, 'CODEX_HOOK_HANDLER_FIELD_UNKNOWN', `Unknown Codex command Hook field "${field}".`, [...fieldPath, field]); + } + if (typeof value.command !== 'string' || value.command.trim().length === 0) + report(context, 'CODEX_HOOK_COMMAND_INVALID', 'command Hook command must be a non-empty string.', [...fieldPath, 'command']); + /** Windows 命令同时兼容 JSON camelCase 和 TOML snake_case 字段。 */ + for (const field of ['commandWindows', 'command_windows'] as const) { + if (value[field] !== undefined && (typeof value[field] !== 'string' || value[field].trim().length === 0)) + report(context, 'CODEX_HOOK_WINDOWS_COMMAND_INVALID', `${field} must be a non-empty string.`, [...fieldPath, field]); + } + if (value.commandWindows !== undefined && value.command_windows !== undefined) { + report(context, 'CODEX_HOOK_WINDOWS_COMMAND_DUPLICATE', 'Use only one Windows command field spelling.', fieldPath); + } + if (value.timeout !== undefined + && (typeof value.timeout !== 'number' || !Number.isFinite(value.timeout) || value.timeout <= 0)) { + report(context, 'CODEX_HOOK_TIMEOUT_INVALID', 'Hook timeout must be a positive finite number of seconds.', [...fieldPath, 'timeout']); + } else if (event === 'SessionEnd' && typeof value.timeout === 'number' && value.timeout > 3) { + report(context, 'CODEX_HOOK_TIMEOUT_LIMIT', 'SessionEnd Hook timeout must not exceed 3 seconds.', [...fieldPath, 'timeout']); + } + if (value.statusMessage !== undefined + && (typeof value.statusMessage !== 'string' || value.statusMessage.trim().length === 0)) { + report(context, 'CODEX_HOOK_STATUS_INVALID', 'Hook statusMessage must be a non-empty string.', [...fieldPath, 'statusMessage']); + } + if (value.additionalContextLimit !== undefined + && (typeof value.additionalContextLimit !== 'number' + || !Number.isInteger(value.additionalContextLimit) + || value.additionalContextLimit < 0)) { + report(context, 'CODEX_HOOK_CONTEXT_LIMIT_INVALID', 'additionalContextLimit must be a non-negative integer.', [...fieldPath, 'additionalContextLimit']); + } + if (value.async !== undefined && typeof value.async !== 'boolean') + report(context, 'CODEX_HOOK_ASYNC_INVALID', 'command Hook async must be a boolean.', [...fieldPath, 'async']); +} + +/** + * 校验 Codex Hook 事件映射及其 matcher 分组。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param value `hooks` 字段中的事件映射候选。 + * @param fieldPath 事件映射在最终配置中的字段路径。 + */ +function validateHookEvents( + context: PlatformValidateContext, + value: JsonValue, + fieldPath: readonly (string | number)[], +): void { + if (!isRecord(value)) { + report(context, 'CODEX_HOOK_EVENTS_INVALID', 'hooks must contain an event mapping.', fieldPath); + return; + } + /** [event, groups] 表示当前遍历的 Codex 事件和 matcher 分组。 */ + for (const [event, groups] of Object.entries(value)) { + /** 当前事件在最终配置中的稳定字段路径。 */ + const eventPath = [...fieldPath, event]; + if (!HOOK_EVENTS.has(event)) { + report(context, 'CODEX_HOOK_EVENT_UNKNOWN', `Unknown Codex Hook event "${event}".`, eventPath); + continue; + } + if (!Array.isArray(groups) || groups.length === 0) { + report(context, 'CODEX_HOOK_GROUPS_INVALID', 'Each Hook event must contain one or more matcher groups.', eventPath); + continue; + } + /** [groupIndex, groupValue] 表示当前事件中的 matcher 分组。 */ + for (const [groupIndex, groupValue] of groups.entries()) { + /** 当前 matcher 分组的稳定字段路径。 */ + const groupPath = [...eventPath, groupIndex]; + if (!isRecord(groupValue)) { + report(context, 'CODEX_HOOK_GROUP_INVALID', 'Hook matcher groups must be JSON objects.', groupPath); + continue; + } + for (const field of Object.keys(groupValue)) { + if (!HOOK_GROUP_FIELDS.has(field)) + report(context, 'CODEX_HOOK_GROUP_FIELD_UNKNOWN', `Unknown Codex Hook group field "${field}".`, [...groupPath, field]); + } + if (groupValue.matcher !== undefined) + validateHookMatcher(context, groupValue.matcher, [...groupPath, 'matcher']); + if (!Array.isArray(groupValue.hooks) || groupValue.hooks.length === 0) { + report(context, 'CODEX_HOOK_HANDLERS_INVALID', 'Hook matcher groups must contain one or more handlers.', [...groupPath, 'hooks']); + continue; + } + /** [handlerIndex, handler] 表示当前 matcher 分组中的 Handler。 */ + for (const [handlerIndex, handler] of groupValue.hooks.entries()) + validateHookHandler(context, event, handler, [...groupPath, 'hooks', handlerIndex]); + } + } +} + +/** + * 校验 Codex `hooks.json` 顶层结构或 Plugin Manifest 内联事件映射。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param value 已解析的 Hook 配置对象。 + * @param fieldPath 配置在 Plugin Manifest 中的字段路径。 + * @param wrapped 是否要求配置使用 `hooks.json` 顶层包装。 + */ +function validateHookConfig( + context: PlatformValidateContext, + value: JsonRecord, + fieldPath: readonly (string | number)[], + wrapped: boolean, +): void { + if (!wrapped && value.hooks === undefined && value.description === undefined) { + validateHookEvents(context, value, fieldPath); + return; + } + for (const field of Object.keys(value)) { + if (!HOOK_CONFIG_FIELDS.has(field)) + report(context, 'CODEX_HOOK_CONFIG_FIELD_UNKNOWN', `Unknown Codex Hook config field "${field}".`, [...fieldPath, field]); + } + if (value.description !== undefined + && (typeof value.description !== 'string' || value.description.trim().length === 0)) { + report(context, 'CODEX_HOOK_DESCRIPTION_INVALID', 'Hook config description must be a non-empty string.', [...fieldPath, 'description']); + } + if (value.hooks === undefined) { + report(context, 'CODEX_HOOKS_REQUIRED', 'Hook config must contain a hooks event mapping.', [...fieldPath, 'hooks']); + return; + } + validateHookEvents(context, value.hooks, [...fieldPath, 'hooks']); +} + +/** + * 读取并校验 Plugin 根内被引用的 Codex `hooks.json`。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 + * @param reference 已通过安装根路径规则的 Hook 配置引用。 + * @param fieldPath 引用在 Plugin Manifest 中的字段路径。 + */ +export async function validateHookFile( + context: PlatformValidateContext, + pluginRoot: string, + reference: string, + fieldPath: readonly (string | number)[], +): Promise { + try { + /** Hook 配置引用相对于当前 Plugin 根解析后的绝对候选路径。 */ + const hookPath = path.join(context.candidate.root, pluginRoot, reference.slice(2)); + /** JSON.parse 返回的未知配置值。 */ + const value: unknown = JSON.parse(await fs.readFile(hookPath, 'utf8')); + if (!isRecord(value)) { + report(context, 'CODEX_HOOK_CONFIG_OBJECT_REQUIRED', 'Hook config must contain a JSON object.', fieldPath); + return; + } + validateHookConfig(context, value, fieldPath, true); + } catch { + report(context, 'CODEX_HOOK_CONFIG_READ_FAILED', 'Hook config reference must contain valid JSON.', fieldPath); + } +} + +/** + * 校验 Hooks 字段允许的引用或内联配置,并验证最终配置内容。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param assets 当前 Package 的 Asset 路径集合。 + * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 + * @param value Hooks 字段候选。 + */ +export async function validateHooks( + context: PlatformValidateContext, + assets: ReadonlySet, + pluginRoot: string, + value: JsonValue, +): Promise { + /** 校验并读取单个 Plugin 根路径引用。 */ + const validatePath = async (reference: string, fieldPath: readonly (string | number)[]): Promise => { + validateReference(context, assets, 'hooks', reference, fieldPath); + if (isSafePluginReference(reference) && referenceExists(assets, reference)) + await validateHookFile(context, pluginRoot, reference, fieldPath); + }; + if (typeof value === 'string') { + await validatePath(value, ['hooks']); + return; + } + if (isRecord(value)) { + validateHookConfig(context, value, ['hooks'], false); + return; + } + if (!Array.isArray(value) || value.length === 0) { + report(context, 'CODEX_HOOKS_INVALID', 'hooks must be a path, paths, an inline object, or inline objects.', ['hooks']); + return; + } + /** 全部为路径或全部为内联对象,避免依赖未声明的混合语义。 */ + const allPaths = value.every(item => typeof item === 'string'); + /** 内联 Hooks 数组是否全部为对象。 */ + const allObjects = value.every(isRecord); + if (!allPaths && !allObjects) { + report(context, 'CODEX_HOOKS_INVALID', 'hooks arrays must contain only paths or only inline objects.', ['hooks']); + return; + } + if (allPaths) { + for (const [index, reference] of value.entries()) + await validatePath(reference as string, ['hooks', index]); + return; + } + for (const [index, inline] of value.entries()) + validateHookConfig(context, inline as JsonRecord, ['hooks', index], false); +} diff --git a/packages/platforms/codex/src/package/validation/index.ts b/packages/platforms/codex/src/package/validation/index.ts new file mode 100644 index 0000000..20eb207 --- /dev/null +++ b/packages/platforms/codex/src/package/validation/index.ts @@ -0,0 +1,22 @@ +/** Codex 主 Plugin 或 Marketplace Distribution 的 validator 组合入口。 */ +import { MARKETPLACE_MANIFEST_PATH, PLUGIN_MANIFEST_PATH } from '../manifest.js'; +import { validatePluginManifest } from './manifest.js'; +import { validateMarketplace } from './marketplace.js'; +import { readJson, report, type PlatformValidateContext } from './shared.js'; + +/** 校验主 Plugin 或 Marketplace Distribution 的最终安装根契约。 */ +export async function validateCodexPackage(context: PlatformValidateContext): Promise { + if (context.candidate.unit.type === 'marketplace') { + /** Distribution 额外要求 Repo Marketplace 固定路径。 */ + const marketplace = await readJson(context, MARKETPLACE_MANIFEST_PATH); + if (marketplace !== undefined) + await validateMarketplace(context, marketplace); + return; + } + /** 主单元始终使用安装根固定 Plugin Manifest。 */ + const plugin = await readJson(context, PLUGIN_MANIFEST_PATH); + if (plugin !== undefined) + await validatePluginManifest(context, plugin); + if (context.candidate.unit.assets.some(asset => asset.path === MARKETPLACE_MANIFEST_PATH)) + report(context, 'CODEX_MARKETPLACE_IN_PRIMARY', 'Primary Plugin must not contain a Marketplace manifest.'); +} diff --git a/packages/platforms/codex/src/package/validation/manifest.ts b/packages/platforms/codex/src/package/validation/manifest.ts new file mode 100644 index 0000000..fae1dff --- /dev/null +++ b/packages/platforms/codex/src/package/validation/manifest.ts @@ -0,0 +1,108 @@ +/** Codex Plugin Manifest validator。 */ +import { isCodexHttpsUrl } from '../protocol.js'; +import { validateInterface } from './assets.js'; +import { validateHookFile, validateHooks } from './hooks.js'; +import { validateMcpFile } from './mcp.js'; +import { validateSkills } from './skills.js'; +import { + isRecord, + isSafePluginReference, + referenceExists, + report, + scopedAssets, + validateReference, + type JsonRecord, + type PlatformValidateContext, +} from './shared.js'; + +/** Codex Plugin Manifest 允许出现的当前官方根字段。 */ +const PLUGIN_FIELDS = new Set([ + 'id', 'name', 'version', 'description', 'author', 'homepage', 'repository', 'license', 'keywords', + 'skills', 'mcpServers', 'apps', 'hooks', 'interface', +]); + +/** Codex Plugin 名称允许使用的官方 ASCII 规则。 */ +const PLUGIN_NAME_PATTERN = /^[\dA-Za-z][\dA-Za-z_-]*$/; + +/** 保守验证完整 Semantic Version 的规则。 */ +const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*)?(?:\+[\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*)?$/; + +/** + * 校验 Plugin Manifest 字段、Skill 根和 Extension 引用。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param manifest 已解析的 Codex Plugin Manifest。 + * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 + */ +export async function validatePluginManifest( + context: PlatformValidateContext, + manifest: JsonRecord, + pluginRoot = '', +): Promise { + /** 当前 Plugin 安装根内的相对 Asset 路径集合。 */ + const assets = scopedAssets(context, pluginRoot); + for (const field of Object.keys(manifest)) { + if (!PLUGIN_FIELDS.has(field)) + report(context, 'CODEX_MANIFEST_FIELD_UNKNOWN', `Unknown Codex Plugin field "${field}".`, [field]); + } + /** Codex Plugin Manifest 的三个稳定必填字符串字段。 */ + const required = ['name', 'version', 'description'] as const; + for (const field of required) { + if (typeof manifest[field] !== 'string' || manifest[field].trim().length === 0) + report(context, 'CODEX_MANIFEST_FIELD_REQUIRED', `${field} must be a non-empty string.`, [field]); + } + if (typeof manifest.name === 'string' + && (manifest.name.length > 64 || !PLUGIN_NAME_PATTERN.test(manifest.name))) { + report(context, 'CODEX_MANIFEST_NAME_INVALID', 'name must use the official ASCII Plugin name format and contain at most 64 characters.', ['name']); + } + if (typeof manifest.version === 'string' + && (manifest.version.length > 64 || !SEMVER_PATTERN.test(manifest.version))) { + report(context, 'CODEX_MANIFEST_VERSION_INVALID', 'version must be a semantic version.', ['version']); + } + if (typeof manifest.description === 'string' && manifest.description.length > 1_024) + report(context, 'CODEX_MANIFEST_DESCRIPTION_INVALID', 'description must contain at most 1024 characters.', ['description']); + if (manifest.author !== undefined) { + /** 通过对象检查后的作者字段。 */ + const author = isRecord(manifest.author) ? manifest.author : undefined; + if (author === undefined || typeof author.name !== 'string' || author.name.trim().length === 0) { + report(context, 'CODEX_MANIFEST_AUTHOR_INVALID', 'author.name must be a non-empty string.', ['author', 'name']); + } else { + for (const field of ['email', 'url'] as const) { + if (author[field] !== undefined && (typeof author[field] !== 'string' || author[field].trim().length === 0)) + report(context, 'CODEX_MANIFEST_AUTHOR_INVALID', `author.${field} must be a non-empty string.`, ['author', field]); + } + if (typeof author.url === 'string' && (!isCodexHttpsUrl(author.url) || author.url.length > 2_048)) + report(context, 'CODEX_MANIFEST_AUTHOR_URL_INVALID', 'author.url must be an HTTPS URL without credentials.', ['author', 'url']); + } + } + for (const field of ['homepage', 'repository', 'license'] as const) { + if (manifest[field] !== undefined && (typeof manifest[field] !== 'string' || manifest[field].trim().length === 0)) + report(context, 'CODEX_MANIFEST_METADATA_INVALID', `${field} must be a non-empty string.`, [field]); + } + if (typeof manifest.homepage === 'string' && (!isCodexHttpsUrl(manifest.homepage) || manifest.homepage.length > 2_048)) + report(context, 'CODEX_MANIFEST_HOMEPAGE_INVALID', 'homepage must be an HTTPS URL without credentials.', ['homepage']); + if (manifest.keywords !== undefined + && (!Array.isArray(manifest.keywords) + || manifest.keywords.some(keyword => typeof keyword !== 'string' || keyword.trim().length === 0) + || new Set(manifest.keywords).size !== manifest.keywords.length)) { + report(context, 'CODEX_MANIFEST_KEYWORDS_INVALID', 'keywords must contain unique non-empty strings.', ['keywords']); + } + if (manifest.skills !== './skills/') + report(context, 'CODEX_SKILLS_PATH_INVALID', 'skills must point to the root ./skills/ directory.', ['skills']); + await validateSkills(context, assets, pluginRoot, typeof manifest.name === 'string' ? manifest.name : undefined); + if (manifest.interface !== undefined) + await validateInterface(context, assets, pluginRoot, manifest.interface); + if (manifest.mcpServers !== undefined) { + if (typeof manifest.mcpServers !== 'string') { + report(context, 'CODEX_MCP_REFERENCE_INVALID', 'mcpServers must be a Plugin-root file path.', ['mcpServers']); + } else { + validateReference(context, assets, 'mcpServers', manifest.mcpServers, ['mcpServers']); + if (isSafePluginReference(manifest.mcpServers) && referenceExists(assets, manifest.mcpServers)) + await validateMcpFile(context, pluginRoot, manifest.mcpServers, ['mcpServers']); + } + } + if (manifest.hooks !== undefined) + await validateHooks(context, assets, pluginRoot, manifest.hooks); + else if (assets.has('hooks/hooks.json')) + await validateHookFile(context, pluginRoot, './hooks/hooks.json', ['hooks']); +} diff --git a/packages/platforms/codex/src/package/validation/marketplace.ts b/packages/platforms/codex/src/package/validation/marketplace.ts new file mode 100644 index 0000000..ae69660 --- /dev/null +++ b/packages/platforms/codex/src/package/validation/marketplace.ts @@ -0,0 +1,110 @@ +/** Codex Marketplace Distribution validator。 */ +import { + CODEX_CATEGORIES, + CODEX_MARKETPLACE_INSTALLATIONS, +} from '../protocol.js'; +import { PLUGIN_MANIFEST_PATH } from '../manifest.js'; +import { validatePluginManifest } from './manifest.js'; +import { + isRecord, + readJson, + report, + type JsonRecord, + type PlatformValidateContext, +} from './shared.js'; + +/** Codex Marketplace 根清单允许出现的字段。 */ +const MARKETPLACE_FIELDS = new Set(['name', 'interface', 'plugins']); + +/** Codex Marketplace 每个 Plugin 条目允许出现的字段。 */ +const MARKETPLACE_PLUGIN_FIELDS = new Set(['name', 'source', 'policy', 'category']); + +/** Codex Marketplace 当前支持的安装策略。 */ +const INSTALLATION_POLICIES = new Set(CODEX_MARKETPLACE_INSTALLATIONS); + +/** Codex 官方插件目录当前接受的分类。 */ +const CATEGORIES = new Set(CODEX_CATEGORIES); + +/** 多 Plugin Marketplace 的本地来源必须使用稳定单元目录。 */ +const MARKETPLACE_PLUGIN_SOURCE_PATTERN = /^\.\/plugins\/[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** + * 校验 Marketplace 根清单和自包含 Plugin 来源。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param marketplace 已解析的 Marketplace 清单。 + */ +export async function validateMarketplace( + context: PlatformValidateContext, + marketplace: JsonRecord, +): Promise { + for (const field of Object.keys(marketplace)) { + if (!MARKETPLACE_FIELDS.has(field)) + report(context, 'CODEX_MARKETPLACE_FIELD_UNKNOWN', `Unknown Codex Marketplace field "${field}".`, [field]); + } + if (typeof marketplace.name !== 'string' || marketplace.name.trim().length === 0) + report(context, 'CODEX_MARKETPLACE_NAME_REQUIRED', 'Marketplace name must be a non-empty string.', ['name']); + if (!isRecord(marketplace.interface) + || typeof marketplace.interface.displayName !== 'string' + || marketplace.interface.displayName.trim().length === 0) { + report(context, 'CODEX_MARKETPLACE_INTERFACE_REQUIRED', 'Marketplace interface.displayName must be present.', ['interface', 'displayName']); + } + if (!Array.isArray(marketplace.plugins) + || marketplace.plugins.length === 0 + || marketplace.plugins.some(entry => !isRecord(entry))) { + report(context, 'CODEX_MARKETPLACE_PLUGIN_REQUIRED', 'Marketplace must contain one or more Plugin entries.', ['plugins']); + return; + } + /** 已验证来源用于阻止两个条目指向同一 Plugin 根。 */ + const sources = new Set(); + /** 已验证名称用于阻止 Marketplace 内出现选择器歧义。 */ + const names = new Set(); + /** [index, entryValue] 表示当前 Marketplace Plugin 条目。 */ + for (const [index, entryValue] of marketplace.plugins.entries()) { + /** plugins 已经整体通过对象检查后的当前条目。 */ + const entry = entryValue as JsonRecord; + for (const field of Object.keys(entry)) { + if (!MARKETPLACE_PLUGIN_FIELDS.has(field)) + report(context, 'CODEX_MARKETPLACE_PLUGIN_FIELD_UNKNOWN', `Unknown Marketplace Plugin field "${field}".`, ['plugins', index, field]); + } + /** 已通过对象形态检查的本地来源候选。 */ + const source = isRecord(entry.source) ? entry.source : undefined; + /** 当前来源中的本地路径候选。 */ + const sourcePath = source?.path; + /** 单项保持兼容根布局,多项必须各自进入稳定 plugins 子目录。 */ + const sourceValid = source?.source === 'local' + && typeof sourcePath === 'string' + && (marketplace.plugins.length === 1 ? sourcePath === './' : MARKETPLACE_PLUGIN_SOURCE_PATTERN.test(sourcePath)); + if (!sourceValid) { + report(context, 'CODEX_MARKETPLACE_SOURCE_INVALID', 'Single-Plugin source must be local "./"; multi-Plugin sources must use "./plugins/".', ['plugins', index, 'source']); + continue; + } + if (sources.has(sourcePath)) + report(context, 'CODEX_MARKETPLACE_SOURCE_DUPLICATE', 'Marketplace Plugin sources must be unique.', ['plugins', index, 'source', 'path']); + sources.add(sourcePath); + /** `./` 对应 Distribution 根,其余来源去掉协议前缀后作为 Plugin 根。 */ + const pluginRoot = sourcePath === './' ? '' : sourcePath.slice(2); + /** 当前来源根内必须存在且可解析的 Codex Plugin Manifest。 */ + const plugin = await readJson(context, pluginRoot === '' ? PLUGIN_MANIFEST_PATH : `${pluginRoot}/${PLUGIN_MANIFEST_PATH}`); + if (plugin === undefined) + continue; + await validatePluginManifest(context, plugin, pluginRoot); + if (entry.name !== plugin.name) + report(context, 'CODEX_MARKETPLACE_PLUGIN_MISMATCH', 'Marketplace Plugin name must match its bundled Plugin Manifest.', ['plugins', index, 'name']); + if (typeof entry.name === 'string') { + /** Marketplace 名称使用 Plugin Manifest 的稳定选择器值。 */ + const name = entry.name; + if (names.has(name)) + report(context, 'CODEX_MARKETPLACE_PLUGIN_DUPLICATE', 'Marketplace Plugin names must be unique.', ['plugins', index, 'name']); + names.add(name); + } + if (!isRecord(entry.policy) + || typeof entry.policy.installation !== 'string' + || !INSTALLATION_POLICIES.has(entry.policy.installation) + || entry.policy.authentication !== 'ON_INSTALL') { + report(context, 'CODEX_MARKETPLACE_POLICY_INVALID', 'Marketplace policy must include a supported installation value and ON_INSTALL authentication.', ['plugins', index, 'policy']); + } + if (typeof entry.category !== 'string' || !CATEGORIES.has(entry.category)) + report(context, 'CODEX_MARKETPLACE_CATEGORY_INVALID', 'Marketplace category must be an official Plugin category.', ['plugins', index, 'category']); + } +} diff --git a/packages/platforms/codex/src/package/validation/mcp.ts b/packages/platforms/codex/src/package/validation/mcp.ts new file mode 100644 index 0000000..eb84137 --- /dev/null +++ b/packages/platforms/codex/src/package/validation/mcp.ts @@ -0,0 +1,127 @@ +/** Codex MCP wire contract validator。 */ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { JsonValue } from '@tokenroll/acplugin/sdk'; +import { + isRecord, + report, + type PlatformValidateContext, +} from './shared.js'; + +/** Codex 本地 stdio MCP descriptor 允许的字段。 */ +const MCP_STDIO_FIELDS = new Set(['command', 'args', 'cwd', 'env', 'env_vars']); + +/** Codex 远程 HTTP MCP descriptor 允许的字段。 */ +const MCP_HTTP_FIELDS = new Set([ + 'url', 'bearer_token_env_var', 'scopes', 'http_headers', 'env_http_headers', +]); + +/** Codex 运行时环境变量名称的保守规则。 */ +const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; + +/** MCP Server key 使用 framework 稳定的 lowercase-kebab 规则。 */ +const SKILL_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** 校验 Codex MCP 的字符串键值映射。 */ +function validateMcpStringMap( + context: PlatformValidateContext, + value: JsonValue | undefined, + code: string, + label: string, + fieldPath: readonly (string | number)[], + environmentValues = false, +): void { + if (value !== undefined && (!isRecord(value) || Object.entries(value).some(([key, entry]) => + key.trim().length === 0 || typeof entry !== 'string' || (environmentValues && !ENV_NAME_PATTERN.test(entry))))) { + report(context, code, `${label} must map non-empty names to valid string values.`, fieldPath); + } +} + +/** 校验 Codex 最终 `.mcp.json` 中的完整 Server 映射。 */ +function validateMcpServers(context: PlatformValidateContext, value: JsonValue, fieldPath: readonly (string | number)[]): void { + if (!isRecord(value)) { + report(context, 'CODEX_MCP_SERVERS_INVALID', 'Codex MCP config must contain a Server object mapping.', fieldPath); + return; + } + for (const [id, candidate] of Object.entries(value)) { + /** 当前 Server 在最终配置中的字段路径。 */ + const serverPath = [...fieldPath, id]; + if (!SKILL_ID_PATTERN.test(id) || !isRecord(candidate)) { + report(context, 'CODEX_MCP_SERVER_INVALID', 'MCP Server ids must use lowercase kebab-case and map to objects.', serverPath); + continue; + } + /** url/command 必须恰好选择一种传输。 */ + const remote = Object.hasOwn(candidate, 'url'); + /** command 表示 Plugin-local stdio 传输。 */ + const local = Object.hasOwn(candidate, 'command'); + if (remote === local) { + report(context, 'CODEX_MCP_TRANSPORT_INVALID', 'MCP Server must declare exactly one of url or command.', serverPath); + continue; + } + /** 当前传输唯一允许的字段集合。 */ + const fields = remote ? MCP_HTTP_FIELDS : MCP_STDIO_FIELDS; + for (const field of Object.keys(candidate)) { + if (!fields.has(field)) + report(context, 'CODEX_MCP_FIELD_UNKNOWN', `Unknown Codex MCP field "${field}".`, [...serverPath, field]); + } + if (local) { + if (typeof candidate.command !== 'string' || candidate.command.trim().length === 0) + report(context, 'CODEX_MCP_COMMAND_INVALID', 'stdio MCP command must be a non-empty string.', [...serverPath, 'command']); + if (candidate.args !== undefined + && (!Array.isArray(candidate.args) || candidate.args.some(argument => typeof argument !== 'string'))) { + report(context, 'CODEX_MCP_ARGS_INVALID', 'stdio MCP args must contain only strings.', [...serverPath, 'args']); + } + if (candidate.cwd !== undefined && candidate.cwd !== '.') + report(context, 'CODEX_MCP_CWD_INVALID', 'Plugin stdio MCP cwd must be the Plugin root ".".', [...serverPath, 'cwd']); + validateMcpStringMap(context, candidate.env, 'CODEX_MCP_ENV_INVALID', 'stdio MCP env', [...serverPath, 'env']); + if (candidate.env_vars !== undefined + && (!Array.isArray(candidate.env_vars) || candidate.env_vars.some(variable => typeof variable !== 'string' || !ENV_NAME_PATTERN.test(variable)) + || new Set(candidate.env_vars).size !== candidate.env_vars.length)) { + report(context, 'CODEX_MCP_ENV_VARS_INVALID', 'stdio MCP env_vars must contain unique environment names.', [...serverPath, 'env_vars']); + } + continue; + } + if (typeof candidate.url !== 'string') { + report(context, 'CODEX_MCP_URL_INVALID', 'HTTP MCP url must be an HTTP(S) URL without credentials.', [...serverPath, 'url']); + } else { + try { + /** Codex remote MCP 不接受 URL 内联凭据。 */ + const url = new URL(candidate.url); + if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username !== '' || url.password !== '') + throw new TypeError('unsafe'); + } catch { + report(context, 'CODEX_MCP_URL_INVALID', 'HTTP MCP url must be an HTTP(S) URL without credentials.', [...serverPath, 'url']); + } + } + if (candidate.bearer_token_env_var !== undefined + && (typeof candidate.bearer_token_env_var !== 'string' || !ENV_NAME_PATTERN.test(candidate.bearer_token_env_var))) { + report(context, 'CODEX_MCP_BEARER_INVALID', 'bearer_token_env_var must be an environment name.', [...serverPath, 'bearer_token_env_var']); + } + if (candidate.scopes !== undefined + && (!Array.isArray(candidate.scopes) || candidate.scopes.length === 0 + || candidate.scopes.some(scope => typeof scope !== 'string' || scope.trim().length === 0) + || new Set(candidate.scopes).size !== candidate.scopes.length)) { + report(context, 'CODEX_MCP_SCOPES_INVALID', 'MCP scopes must contain unique non-empty strings.', [...serverPath, 'scopes']); + } + validateMcpStringMap(context, candidate.http_headers, 'CODEX_MCP_HEADERS_INVALID', 'HTTP MCP headers', [...serverPath, 'http_headers']); + validateMcpStringMap(context, candidate.env_http_headers, 'CODEX_MCP_ENV_HEADERS_INVALID', 'HTTP MCP env headers', [...serverPath, 'env_http_headers'], true); + } +} + +/** 读取并校验 Codex Plugin 根内被引用的 `.mcp.json`。 */ +export async function validateMcpFile( + context: PlatformValidateContext, + pluginRoot: string, + reference: string, + fieldPath: readonly (string | number)[], +): Promise { + try { + /** MCP 配置引用相对于当前 Plugin 根解析。 */ + const mcpPath = path.join(context.candidate.root, pluginRoot, reference.slice(2)); + /** Codex `.mcp.json` 顶层直接是 Server 映射。 */ + const value: unknown = JSON.parse(await fs.readFile(mcpPath, 'utf8')); + validateMcpServers(context, value as JsonValue, fieldPath); + } catch { + report(context, 'CODEX_MCP_CONFIG_READ_FAILED', 'mcpServers reference must contain valid JSON.', fieldPath); + } +} diff --git a/packages/platforms/codex/src/package/validation/shared.ts b/packages/platforms/codex/src/package/validation/shared.ts new file mode 100644 index 0000000..ccd687a --- /dev/null +++ b/packages/platforms/codex/src/package/validation/shared.ts @@ -0,0 +1,181 @@ +/** Codex candidate validator 共用的只读边界。 */ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { JsonValue, ValidatePackageContext } from '@tokenroll/acplugin/sdk'; +import { parseDocument } from 'yaml'; + +/** Codex validator 只消费 SDK 的最终 Package candidate Context。 */ +export type PlatformValidateContext = ValidatePackageContext; + +/** JSON 对象的运行时可索引类型。 */ +export type JsonRecord = Record; + +/** + * 判断未知值是否为非数组 JSON 对象。 + * + * @param value 从候选清单解析的未知值。 + * @returns 可以按字段读取时返回 true。 + */ +export function isRecord(value: unknown): value is JsonRecord { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * 向 Core 提交 Codex 候选校验错误。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param code 稳定诊断码。 + * @param message 不包含宿主绝对路径的错误信息。 + * @param fieldPath 可选的清单字段位置。 + */ +export function report( + context: PlatformValidateContext, + code: string, + message: string, + fieldPath?: readonly (string | number)[], +): void { + context.diagnostics.report({ + code, + severity: 'error', + message, + ...(fieldPath === undefined ? {} : { fieldPath }), + }); +} + +/** + * 从候选安装根读取并解析 JSON 文件。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param assetPath 候选根内的规范 Asset 路径。 + * @returns JSON 对象;缺失或格式错误时提交诊断并返回 undefined。 + */ +export async function readJson( + context: PlatformValidateContext, + assetPath: string, +): Promise { + try { + /** 从 Core 已安全物化的候选根读取清单文本。 */ + const source = await fs.readFile(path.join(context.candidate.root, assetPath), 'utf8'); + /** JSON.parse 的未知结果仍需验证顶层对象形态。 */ + const value: unknown = JSON.parse(source); + if (!isRecord(value)) { + report(context, 'CODEX_MANIFEST_OBJECT_REQUIRED', `${assetPath} must contain a JSON object.`); + return undefined; + } + return value; + } catch { + report(context, 'CODEX_MANIFEST_READ_FAILED', `${assetPath} must be present and contain valid JSON.`); + return undefined; + } +} + +/** + * 解析 YAML 并要求顶层为普通映射。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param source 待解析的 YAML 文本。 + * @param assetPath 用于稳定诊断的相对 Asset 路径。 + * @returns 无语法错误的 JSON 兼容对象,否则返回 undefined。 + */ +export function parseYamlObject( + context: PlatformValidateContext, + source: string, + assetPath: string, +): JsonRecord | undefined { + try { + /** 保留 YAML parser errors 以拒绝重复键和其他不规范输入。 */ + const document = parseDocument(source, { uniqueKeys: true }); + if (document.errors.length > 0) + throw new Error('Malformed YAML.'); + /** YAML 文档转换后的未知顶层值。 */ + const value: unknown = document.toJSON(); + if (!isRecord(value)) { + report(context, 'CODEX_YAML_OBJECT_REQUIRED', `${assetPath} must contain a YAML mapping.`); + return undefined; + } + return value; + } catch { + report(context, 'CODEX_YAML_INVALID', `${assetPath} must contain valid YAML.`); + return undefined; + } +} + +/** + * 判断清单路径引用是否严格位于当前 Plugin 安装根。 + * + * @param reference Codex Manifest 中的相对路径。 + * @returns 路径使用 `./`、不逃逸且不指向根本身时返回 true。 + */ +export function isSafePluginReference(reference: string): boolean { + if (!reference.startsWith('./') || reference.includes('\\') || reference.includes('\0')) + return false; + /** 去掉协议前缀后执行 POSIX 规范化的路径片段。 */ + const relative = reference.slice(2); + /** 规范化路径用于拒绝空引用和父目录逃逸。 */ + const normalized = path.posix.normalize(relative); + return relative.length > 0 + && normalized !== '.' + && normalized !== '..' + && !normalized.startsWith('../') + && !path.posix.isAbsolute(normalized); +} + +/** + * 判断 Asset 集合是否包含被引用文件或目录。 + * + * @param assets 当前 Package 的规范路径集合。 + * @param reference 已通过安全规则验证的 Manifest 引用。 + * @returns 精确文件或目录前缀存在时返回 true。 + */ +export function referenceExists(assets: ReadonlySet, reference: string): boolean { + /** 清单引用去掉 `./` 和结尾斜线后的 Asset 路径。 */ + const target = reference.slice(2).replace(/\/+$/u, ''); + if (assets.has(target)) + return true; + for (const asset of assets) { + if (asset.startsWith(`${target}/`)) + return true; + } + return false; +} + +/** + * 把 Distribution 中某个 Plugin 子树转换为安装根相对 Asset 集合。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param pluginRoot Plugin 相对于 Distribution 根的无前导点路径。 + * @returns 去掉 Plugin 根前缀后的 Asset 路径集合。 + */ +export function scopedAssets(context: PlatformValidateContext, pluginRoot: string): ReadonlySet { + /** 根 Plugin 不需要过滤或裁剪路径。 */ + if (pluginRoot === '') + return new Set(context.candidate.unit.assets.map(asset => asset.path)); + /** 嵌套 Plugin 全部 Asset 共同使用的固定目录前缀。 */ + const prefix = `${pluginRoot}/`; + return new Set(context.candidate.unit.assets + .filter(asset => asset.path.startsWith(prefix)) + .map(asset => asset.path.slice(prefix.length))); +} + +/** + * 校验单个 Manifest 路径的安全性与存在性。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param assets 当前 Package 的 Asset 路径集合。 + * @param field 当前引用所属字段。 + * @param reference 待校验路径。 + * @param fieldPath 精确诊断位置。 + */ +export function validateReference( + context: PlatformValidateContext, + assets: ReadonlySet, + field: string, + reference: string, + fieldPath: readonly (string | number)[], +): void { + if (!isSafePluginReference(reference)) { + report(context, 'CODEX_MANIFEST_REFERENCE_UNSAFE', `${field} must start with ./ and stay inside the Plugin root.`, fieldPath); + } else if (!referenceExists(assets, reference)) { + report(context, 'CODEX_MANIFEST_REFERENCE_MISSING', `${field} references a missing Plugin file or directory.`, fieldPath); + } +} diff --git a/packages/platforms/codex/src/package/validation/skills.ts b/packages/platforms/codex/src/package/validation/skills.ts new file mode 100644 index 0000000..4240e5d --- /dev/null +++ b/packages/platforms/codex/src/package/validation/skills.ts @@ -0,0 +1,272 @@ +/** Codex Skill Markdown 与 agents/openai.yaml validator。 */ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { CODEX_BRAND_COLOR_PATTERN, CODEX_SKILL_PRODUCTS } from '../protocol.js'; +import { + isRecord, + isSafePluginReference, + parseYamlObject, + report, + type PlatformValidateContext, +} from './shared.js'; + +/** Codex Skill `agents/openai.yaml` 允许出现的根字段。 */ +const SKILL_METADATA_FIELDS = new Set(['interface', 'policy', 'dependencies']); + +/** Codex Skill 元数据 `interface` 允许出现的 snake_case 字段。 */ +const SKILL_INTERFACE_FIELDS = new Set([ + 'display_name', 'short_description', 'icon_small', 'icon_large', 'brand_color', 'default_prompt', +]); + +/** + * 按 UTF-16 code unit 比较 Codex Skill ID,不依赖宿主 locale/ICU。 + * + * @param left 左侧 ID。 + * @param right 右侧 ID。 + * @returns 与 Array.sort 约定一致的 -1、0 或 1。 + */ +function compareCodeUnits(left: string, right: string): number { + if (left === right) + return 0; + return left < right ? -1 : 1; +} + +/** Codex Skill 元数据 `policy` 允许出现的字段。 */ +const SKILL_POLICY_FIELDS = new Set(['products', 'allow_implicit_invocation']); + +/** Codex Skill 元数据支持的产品范围。 */ +const SKILL_PRODUCTS = new Set(CODEX_SKILL_PRODUCTS); + +/** Canonical 与 fallback Skill 最终目录使用的小写 kebab-case 规则。 */ +const SKILL_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** + * 校验 Skill 元数据中的相对资源引用。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param assets 当前 Package 的 Asset 路径集合。 + * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 + * @param skillId 当前 Skill 的最终目录 ID。 + * @param field 元数据资源字段名。 + * @param reference 相对于 Skill 根的资源路径。 + */ +function validateSkillAssetReference( + context: PlatformValidateContext, + assets: ReadonlySet, + skillId: string, + field: string, + reference: string, +): void { + /** Skill 资源遵循同一 `./` 安全规则,但解析基准是当前 Skill 根。 */ + if (!isSafePluginReference(reference)) { + report(context, 'CODEX_SKILL_ASSET_UNSAFE', `${field} must start with ./ and stay inside the Skill root.`, ['skills', skillId, 'agents', 'openai.yaml', 'interface', field]); + return; + } + /** Skill 相对引用转换后的完整 Asset 路径。 */ + const assetPath = `skills/${skillId}/${reference.slice(2)}`; + if (!assets.has(assetPath)) { + report(context, 'CODEX_SKILL_ASSET_MISSING', `${field} references a missing Skill asset.`, ['skills', skillId, 'agents', 'openai.yaml', 'interface', field]); + } +} + +/** + * 校验一个 Skill 的 `agents/openai.yaml` 官方结构。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param assets 当前 Package 的 Asset 路径集合。 + * @param skillId 当前 Skill 的最终目录 ID。 + */ +async function validateSkillMetadata( + context: PlatformValidateContext, + assets: ReadonlySet, + pluginRoot: string, + skillId: string, +): Promise { + /** 当前 Skill 元数据的固定 Asset 路径。 */ + const metadataPath = `skills/${skillId}/agents/openai.yaml`; + if (!assets.has(metadataPath)) + return; + try { + /** 从已物化候选读取 UTF-8 Skill 元数据。 */ + const source = await fs.readFile(path.join(context.candidate.root, pluginRoot, metadataPath), 'utf8'); + /** YAML 顶层必须为可验证的映射。 */ + const metadata = parseYamlObject(context, source, metadataPath); + if (metadata === undefined) + return; + for (const field of Object.keys(metadata)) { + if (!SKILL_METADATA_FIELDS.has(field)) + report(context, 'CODEX_SKILL_METADATA_FIELD_UNKNOWN', `Unknown ${metadataPath} field "${field}".`); + } + if (!isRecord(metadata.interface)) { + report(context, 'CODEX_SKILL_INTERFACE_REQUIRED', `${metadataPath} must contain an interface mapping.`); + return; + } + /** Skill interface 中已经通过对象校验的字段。 */ + const skillInterface = metadata.interface; + for (const field of Object.keys(skillInterface)) { + if (!SKILL_INTERFACE_FIELDS.has(field)) + report(context, 'CODEX_SKILL_INTERFACE_FIELD_UNKNOWN', `Unknown Skill interface field "${field}".`); + } + /** Skill 元数据存在时必须同时提供的两个展示字段。 */ + for (const field of ['display_name', 'short_description'] as const) { + if (typeof skillInterface[field] !== 'string' || skillInterface[field].trim().length === 0) + report(context, 'CODEX_SKILL_INTERFACE_FIELD_REQUIRED', `Skill interface.${field} must be a non-empty string.`); + } + for (const field of ['icon_small', 'icon_large'] as const) { + /** 当前可选 Skill 图片引用。 */ + const candidate = skillInterface[field]; + if (candidate !== undefined) { + if (typeof candidate !== 'string' || candidate.trim().length === 0) + report(context, 'CODEX_SKILL_ASSET_INVALID', `Skill interface.${field} must be a non-empty path.`); + else + validateSkillAssetReference(context, assets, skillId, field, candidate); + } + } + if (skillInterface.brand_color !== undefined + && (typeof skillInterface.brand_color !== 'string' || !CODEX_BRAND_COLOR_PATTERN.test(skillInterface.brand_color))) { + report(context, 'CODEX_SKILL_BRAND_COLOR_INVALID', 'Skill interface.brand_color must be a six-digit hexadecimal color.'); + } + if (skillInterface.default_prompt !== undefined + && (typeof skillInterface.default_prompt !== 'string' || skillInterface.default_prompt.trim().length === 0)) { + report(context, 'CODEX_SKILL_DEFAULT_PROMPT_INVALID', 'Skill interface.default_prompt must be a non-empty string.'); + } + if (metadata.policy !== undefined) { + if (!isRecord(metadata.policy)) { + report(context, 'CODEX_SKILL_POLICY_INVALID', 'Skill policy must be a YAML mapping.'); + } else { + for (const field of Object.keys(metadata.policy)) { + if (!SKILL_POLICY_FIELDS.has(field)) + report(context, 'CODEX_SKILL_POLICY_FIELD_UNKNOWN', `Unknown Skill policy field "${field}".`); + } + if (metadata.policy.allow_implicit_invocation !== undefined + && typeof metadata.policy.allow_implicit_invocation !== 'boolean') { + report(context, 'CODEX_SKILL_POLICY_INVALID', 'Skill policy.allow_implicit_invocation must be a boolean.'); + } + if (metadata.policy.products !== undefined + && (!Array.isArray(metadata.policy.products) + || metadata.policy.products.length === 0 + || metadata.policy.products.some(product => typeof product !== 'string' || !SKILL_PRODUCTS.has(product)) + || new Set(metadata.policy.products).size !== metadata.policy.products.length)) { + report(context, 'CODEX_SKILL_POLICY_INVALID', 'Skill policy.products must contain CHAT, CODEX, or both without duplicates.'); + } + } + } + if (metadata.dependencies !== undefined) { + if (!isRecord(metadata.dependencies) + || Object.keys(metadata.dependencies).some(field => field !== 'tools') + || !Array.isArray(metadata.dependencies.tools)) { + report(context, 'CODEX_SKILL_DEPENDENCIES_INVALID', 'Skill dependencies may contain only a tools array.'); + } + } + } catch { + report(context, 'CODEX_SKILL_METADATA_READ_FAILED', `${metadataPath} must be readable UTF-8 YAML.`); + } +} + +/** + * 校验一个最终 Skill 的 Markdown、frontmatter、正文与可选元数据。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param assets 当前 Package 的 Asset 路径集合。 + * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 + * @param pluginName 当前 Plugin 的稳定机器名称。 + * @param skillId 当前 Skill 的最终目录 ID。 + * @param names 已验证 Skill frontmatter 名称的全局索引。 + */ +async function validateSkill( + context: PlatformValidateContext, + assets: ReadonlySet, + pluginRoot: string, + pluginName: string | undefined, + skillId: string, + names: Set, +): Promise { + /** 当前 Skill Manifest 的固定 Asset 路径。 */ + const manifestPath = `skills/${skillId}/SKILL.md`; + if (!assets.has(manifestPath)) { + report(context, 'CODEX_SKILL_MANIFEST_MISSING', `Skill directory "${skillId}" must contain SKILL.md.`, ['skills', skillId]); + return; + } + try { + /** 从已物化候选读取最终 Skill Markdown。 */ + const source = await fs.readFile(path.join(context.candidate.root, pluginRoot, manifestPath), 'utf8'); + /** Frontmatter 与正文使用固定边界,拒绝缺失或未闭合标记。 */ + const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/u.exec(source); + if (match === null) { + report(context, 'CODEX_SKILL_FRONTMATTER_INVALID', `${manifestPath} must start with closed YAML frontmatter.`); + return; + } + /** 已从正则边界提取的 YAML frontmatter。 */ + const frontmatter = parseYamlObject(context, match[1]!, manifestPath); + if (frontmatter === undefined) + return; + /** frontmatter 声明的 Skill 机器名称。 */ + const name = frontmatter.name; + if (typeof name !== 'string' || !SKILL_ID_PATTERN.test(name)) { + report(context, 'CODEX_SKILL_NAME_INVALID', `${manifestPath} name must use lowercase kebab-case.`); + } else { + /** Skill name 按平台最终选择器语义执行大小写不敏感唯一性。 */ + const key = name.toLocaleLowerCase('en-US'); + if (names.has(key)) + report(context, 'CODEX_SKILL_NAME_DUPLICATE', `Skill name "${name}" is duplicated.`); + names.add(key); + if (name !== skillId) + report(context, 'CODEX_SKILL_NAME_MISMATCH', `${manifestPath} name must match its directory ID "${skillId}".`); + if (pluginName !== undefined && `${pluginName}:${name}`.length > 64) + report(context, 'CODEX_SKILL_IDENTITY_TOO_LONG', `Plugin and Skill identity "${pluginName}:${name}" exceeds 64 characters.`); + } + if (typeof frontmatter.description !== 'string' + || frontmatter.description.trim().length === 0 + || frontmatter.description.length > 1_024) { + report(context, 'CODEX_SKILL_DESCRIPTION_INVALID', `${manifestPath} description must contain 1 to 1024 characters.`); + } + if (match[2]!.trim().length === 0) + report(context, 'CODEX_SKILL_BODY_EMPTY', `${manifestPath} instructions must not be empty.`); + await validateSkillMetadata(context, assets, pluginRoot, skillId); + } catch { + report(context, 'CODEX_SKILL_READ_FAILED', `${manifestPath} must be readable UTF-8 Markdown.`); + } +} + +/** + * 校验 `skills/` 根下每个直接子目录及其内容协议。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param assets 当前 Package 的 Asset 路径集合。 + * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 + * @param pluginName 当前 Plugin 的稳定机器名称。 + */ +export async function validateSkills( + context: PlatformValidateContext, + assets: ReadonlySet, + pluginRoot: string, + pluginName: string | undefined, +): Promise { + /** 从任意 Skill Asset 收集的直接子目录 ID。 */ + const directories = new Set(); + for (const asset of assets) { + if (!asset.startsWith('skills/')) + continue; + /** 当前 Skill Asset 的 POSIX 路径片段。 */ + const segments = asset.split('/'); + if (segments.length < 3 || segments[1] === '') { + report(context, 'CODEX_SKILL_PATH_INVALID', `Invalid Skill Asset path "${asset}".`, ['skills']); + continue; + } + directories.add(segments[1]!); + if (asset.endsWith('/SKILL.md') && segments.length !== 3) + report(context, 'CODEX_SKILL_MANIFEST_NESTED', 'SKILL.md must be an immediate child of its Skill directory.', ['skills', segments[1]!]); + } + if (directories.size === 0) { + report(context, 'CODEX_SKILL_REQUIRED', 'A Codex Plugin must contain at least one immediate child Skill.', ['skills']); + return; + } + /** 已验证 Skill frontmatter 名称的全局唯一性集合。 */ + const names = new Set(); + /** skillId 表示当前排序后的 Skill,用于生成确定诊断顺序。 */ + for (const skillId of [...directories].sort(compareCodeUnits)) { + if (!SKILL_ID_PATTERN.test(skillId)) + report(context, 'CODEX_SKILL_DIRECTORY_INVALID', `Skill directory "${skillId}" must use lowercase kebab-case.`, ['skills', skillId]); + await validateSkill(context, assets, pluginRoot, pluginName, skillId, names); + } +} diff --git a/packages/platforms/codex/src/types.ts b/packages/platforms/codex/src/types.ts new file mode 100644 index 0000000..76adaf7 --- /dev/null +++ b/packages/platforms/codex/src/types.ts @@ -0,0 +1,105 @@ +import type { JsonValue, PluginAuthor } from '@tokenroll/acplugin/sdk'; +import type { CodexCategory, CodexMarketplaceInstallation } from './package/protocol.js'; + +export type { CodexCategory, CodexMarketplaceInstallation } from './package/protocol.js'; + +/** Codex Plugin `interface` 中由平台工厂管理的展示选项。 */ +export interface CodexInterfaceOptions { + readonly shortDescription?: string; + readonly longDescription?: string; + readonly developerName?: string; + readonly category?: CodexCategory; + readonly capabilities?: readonly string[]; + readonly websiteURL?: string; + readonly privacyPolicyURL?: string; + readonly termsOfServiceURL?: string; + readonly supportURL?: string; + readonly defaultPrompt?: string | readonly string[]; + readonly brandColor?: string; + readonly brandColorDark?: string; + readonly composerIcon?: string; + readonly logo?: string; + readonly screenshots?: readonly string[]; +} + +/** Codex Marketplace 单 Plugin 条目的策略选项。 */ +export interface CodexMarketplacePolicyOptions { + readonly installation?: CodexMarketplaceInstallation; +} + +/** Codex Marketplace 的可配置根级展示与安装选项。 */ +export interface CodexMarketplaceOptions { + readonly name?: string; + readonly displayName?: string; + readonly category?: CodexCategory; + readonly policy?: CodexMarketplacePolicyOptions; +} + +/** 创建 Codex Platform 时可声明的公开选项。 */ +export interface CodexPlatformOptions { + readonly strict?: boolean; + readonly interface?: CodexInterfaceOptions; + readonly marketplace?: CodexMarketplaceOptions; +} + +/** Codex Plugin 清单中面向安装界面的完整展示区域。 */ +export interface CodexPluginInterface { + readonly displayName: string; + readonly shortDescription: string; + readonly longDescription: string; + readonly developerName: string; + readonly category?: CodexCategory; + readonly capabilities?: readonly string[]; + readonly websiteURL?: string; + readonly privacyPolicyURL?: string; + readonly termsOfServiceURL?: string; + readonly supportURL?: string; + readonly defaultPrompt?: string | readonly string[]; + readonly brandColor?: string; + readonly brandColorDark?: string; + readonly composerIcon?: string; + readonly logo?: string; + readonly screenshots?: readonly string[]; +} + +/** Codex Plugin Manifest 的平台所有字段。 */ +export interface CodexPluginManifest { + readonly name: string; + readonly version: string; + readonly description: string; + readonly author?: PluginAuthor; + readonly homepage?: string; + readonly repository?: string; + readonly license?: string; + readonly keywords?: readonly string[]; + readonly skills: './skills/'; + readonly interface?: CodexPluginInterface; + readonly hooks?: JsonValue; + readonly mcpServers?: string; +} + +/** Codex Marketplace 文件中的本地 Plugin 来源。 */ +export interface CodexMarketplaceSource { + readonly source: 'local'; + readonly path: './' | `./plugins/${string}`; +} + +/** Codex Marketplace 文件中的单个 Plugin 条目。 */ +export interface CodexMarketplacePlugin { + readonly name: string; + readonly source: CodexMarketplaceSource; + readonly policy: { + readonly installation: CodexMarketplaceInstallation; + readonly authentication: 'ON_INSTALL'; + }; + readonly category: CodexCategory; +} + +/** Codex 自包含 Marketplace 清单。 */ +export interface CodexMarketplaceManifest { + readonly name: string; + readonly interface: { + readonly displayName: string; + }; + readonly plugins: readonly CodexMarketplacePlugin[]; +} diff --git a/packages/platforms/codex/test/golden/.agents/plugins/marketplace.json b/packages/platforms/codex/test/golden/.agents/plugins/marketplace.json new file mode 100644 index 0000000..066ccb8 --- /dev/null +++ b/packages/platforms/codex/test/golden/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "interface": { + "displayName": "Release Tools Marketplace" + }, + "name": "release-tools-marketplace", + "plugins": [ + { + "category": "Developer Tools", + "name": "release-tools", + "policy": { + "authentication": "ON_INSTALL", + "installation": "INSTALLED_BY_DEFAULT" + }, + "source": { + "path": "./", + "source": "local" + } + } + ] +} diff --git a/packages/platforms/codex/test/golden/.codex-plugin/plugin.json b/packages/platforms/codex/test/golden/.codex-plugin/plugin.json new file mode 100644 index 0000000..e90b9df --- /dev/null +++ b/packages/platforms/codex/test/golden/.codex-plugin/plugin.json @@ -0,0 +1,34 @@ +{ + "author": { + "email": "maintainers@example.com", + "name": "TokenRoll", + "url": "https://github.com/TokenRollAI" + }, + "description": "Release workflow tools.", + "homepage": "https://example.com/release-tools", + "interface": { + "brandColor": "#10A37F", + "capabilities": [ + "Prepare releases", + "Review changes" + ], + "category": "Developer Tools", + "composerIcon": "./assets/logo.svg", + "defaultPrompt": "Use Release Tools to review this change.", + "developerName": "TokenRoll", + "displayName": "Release Tools", + "logo": "./assets/logo.svg", + "longDescription": "Release workflow tools.", + "shortDescription": "Release workflow tools.", + "websiteURL": "https://example.com/release-tools" + }, + "keywords": [ + "release", + "review" + ], + "license": "MIT", + "name": "release-tools", + "repository": "https://github.com/TokenRollAI/release-tools", + "skills": "./skills/", + "version": "1.2.3" +} diff --git a/packages/platforms/codex/test/golden/skills/release-tools-release/SKILL.md b/packages/platforms/codex/test/golden/skills/release-tools-release/SKILL.md new file mode 100644 index 0000000..25881da --- /dev/null +++ b/packages/platforms/codex/test/golden/skills/release-tools-release/SKILL.md @@ -0,0 +1,5 @@ +--- +description: Prepare a release. +name: release-tools-release +--- +Prepare release the arguments supplied with this explicit invocation. diff --git a/packages/platforms/codex/test/golden/skills/release-tools-release/agents/openai.yaml b/packages/platforms/codex/test/golden/skills/release-tools-release/agents/openai.yaml new file mode 100644 index 0000000..abb36f7 --- /dev/null +++ b/packages/platforms/codex/test/golden/skills/release-tools-release/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: Release command + short_description: Prepare a release +policy: + allow_implicit_invocation: false diff --git a/packages/platforms/codex/test/golden/skills/review/SKILL.md b/packages/platforms/codex/test/golden/skills/review/SKILL.md new file mode 100644 index 0000000..e5e3db7 --- /dev/null +++ b/packages/platforms/codex/test/golden/skills/review/SKILL.md @@ -0,0 +1,5 @@ +--- +description: Review the current change. +name: review +--- +Review the implementation. diff --git a/packages/platforms/codex/test/golden/skills/review/agents/openai.yaml b/packages/platforms/codex/test/golden/skills/review/agents/openai.yaml new file mode 100644 index 0000000..f42bec2 --- /dev/null +++ b/packages/platforms/codex/test/golden/skills/review/agents/openai.yaml @@ -0,0 +1,8 @@ +interface: + brand_color: "#10A37F" + display_name: Review change + short_description: Review a change +policy: + allow_implicit_invocation: false + products: + - CODEX diff --git a/packages/platforms/codex/test/platform.test.ts b/packages/platforms/codex/test/platform.test.ts new file mode 100644 index 0000000..79f298d --- /dev/null +++ b/packages/platforms/codex/test/platform.test.ts @@ -0,0 +1,510 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + defineExtension, + resolveKernelConfig, + runKernelBuildSession, + type AcpluginExtension, + type ConfigCommand, + type PlatformContributor, +} from '@acplugin/core'; +import { codex } from '../src/index.js'; +import { MARKETPLACE_MANIFEST_PATH, PLUGIN_MANIFEST_PATH } from '../src/package/manifest.js'; + +/** 测试结束后统一删除的临时工程根。 */ +const temporaryRoots: string[] = []; + +/** Golden 文件相对于当前测试模块的固定目录。 */ +const goldenRoot = path.join(import.meta.dirname, 'golden'); + +/** 创建带最小配置占位符且会自动清理的临时工程。 */ +async function temporaryProject(): Promise { + /** root 是当前测试独占工程根。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-codex-platform-')); + temporaryRoots.push(root); + await fs.mkdir(path.join(root, 'src'), { recursive: true }); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), 'export default {}\n'); + return root; +} + +/** 写入原生 Skill、转换 Command、Public branding 和 Core Runtime。 */ +async function writeSupportedProject(root: string): Promise { + await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/skills/review/references'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/runtime'), { recursive: true }); + await fs.mkdir(path.join(root, 'public/assets'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/commands/release.md'), `--- +description: Prepare a release. +platforms: + codex: + displayName: Release command + shortDescription: Prepare a release +--- +Prepare release {{arguments}}. +`); + await fs.writeFile(path.join(root, 'src/skills/review/SKILL.md'), `--- +description: Review the current change. +invocation: + user: true + model: false +platforms: + codex: + displayName: Review change + shortDescription: Review a change + brandColor: "#10A37F" + products: + - CODEX +--- +Review the implementation. +`); + await fs.writeFile(path.join(root, 'src/skills/review/references/checklist.md'), 'Review checklist.\n'); + await fs.writeFile(path.join(root, 'src/runtime/cli.ts'), 'process.stdout.write("runtime-ready\\n");\n'); + await fs.writeFile(path.join(root, 'public/assets/logo.svg'), + '\n'); +} + +/** 执行一次只包含 Codex 的真实 Kernel v2 BuildSession。 */ +async function run(input: { + readonly root: string; + readonly command?: ConfigCommand; + readonly platform?: ReturnType; + readonly extensions?: readonly AcpluginExtension[]; + readonly commit?: boolean; +}) { + /** command 决定 lifecycle 语义,commit 只允许 build 使用。 */ + const command = input.command ?? 'build'; + /** resolved 使用公开 Project API 的相同 config resolver。 */ + const resolved = resolveKernelConfig({ + name: 'release-tools', + version: '1.2.3', + description: 'Release workflow tools.', + displayName: 'Release Tools', + author: { name: 'TokenRoll', email: 'maintainers@example.com', url: 'https://github.com/TokenRollAI' }, + homepage: 'https://example.com/release-tools', + repository: 'https://github.com/TokenRollAI/release-tools', + license: 'MIT', + keywords: ['release', 'review'], + platforms: [input.platform ?? codex()], + extensions: input.extensions ?? [], + build: { outDir: 'dist', strict: true }, + }, { + projectRoot: input.root, + configFile: path.join(input.root, 'acplugin.config.ts'), + command, + mode: 'production', + }); + expect(resolved.diagnostics).toEqual([]); + return (await runKernelBuildSession({ + config: resolved.config!, frameworkVersion: 'test', + commit: command === 'build' && (input.commit ?? true), + })).report; +} + +/** 对比构建结果和仓库内确定性 Golden 字节。 */ +async function expectGolden(actual: string, golden: string): Promise { + await expect(fs.readFile(actual)).resolves.toEqual(await fs.readFile(path.join(goldenRoot, golden))); +} + +/** 创建向 Codex Package add-only 贡献一个资源的测试 Extension。 */ +function contributionExtension(input: { + readonly id: string; + readonly field?: 'hooks' | 'mcpServers'; + readonly value?: string; + readonly path: string; + readonly bytes: string; +}): AcpluginExtension { + return defineExtension({ + id: input.id, + apiVersion: '1', + resourceRoots: [], + /** Session 覆盖完整 Resource 与 Contributor 生命周期。 */ + createSession: () => ({ + /** 空对象标记 Fixture 本轮已发现。 */ + discover: () => ({}), + /** tuple 用于验证贡献的兼容性覆盖。 */ + validate: (_context, state) => ({ + state, subjects: [{ subject: `fixture:${input.id}`, capabilities: ['delivery'] }], + }), + /** bytes 只通过 Extension owner-scoped AssetService 签发。 */ + async build({ assets }, state) { + return { state: { + state, + asset: await assets.fromBytes({ + bytes: input.bytes, + origin: { operation: 'codex-fixture', subjects: [`fixture:${input.id}`] }, + }), + } }; + }, + contributors: [{ + platform: 'codex', + platformApiVersion: '1', + /** Contributor 只能占用声明点、追加 Asset 并覆盖自己的 tuple。 */ + contribute: (_context, built) => ({ + ...(input.field === undefined + ? {} + : { + documentFields: [{ document: 'plugin-manifest', path: [input.field], value: input.value! }], + }), + assets: [{ path: input.path, asset: built.asset }], + compatibility: [{ + subject: `fixture:${input.id}`, capability: 'delivery', level: 'native', + reason: 'The fixture is delivered through the Codex Package contribution contract.', + }], + }), + }], + }), + }); +} + +/** 创建 Codex 必须显式拒绝的非空私有 Component contribution。 */ +function unsupportedComponentContribution(): AcpluginExtension { + const contributor: PlatformContributor, { readonly kind: 'fixture-component' }> = { + platform: 'codex', + platformApiVersion: '1', + contribute: () => ({ + components: [{ subject: 'fixture:private-component', value: { kind: 'fixture-component' } }], + compatibility: [{ + subject: 'fixture:private-component', capability: 'delivery', level: 'native', + reason: 'The fixture requests private Component delivery.', + }], + }), + }; + return defineExtension({ + id: 'private-component-fixture', + apiVersion: '1', + resourceRoots: [], + createSession: () => ({ + discover: () => ({}), + validate: (_context, state) => ({ + state, subjects: [{ subject: 'fixture:private-component', capabilities: ['delivery'] }], + }), + build: (_context, state) => ({ state }), + contributors: [contributor], + }), + }); +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('Codex Platform Package API', () => { + it('builds Skill, plugin-prefixed Command, Public, Runtime, metadata, and current protocol goldens', async () => { + /** root 包含 Codex 首期全部严格可接受能力。 */ + const root = await temporaryProject(); + await writeSupportedProject(root); + /** platform 配置完整官方安装 interface。 */ + const platform = codex({ + interface: { + category: 'Developer Tools', + capabilities: ['Prepare releases', 'Review changes'], + defaultPrompt: 'Use Release Tools to review this change.', + brandColor: '#10A37F', + composerIcon: './assets/logo.svg', + logo: './assets/logo.svg', + }, + }); + /** report 来自真实 build 和受管事务。 */ + const report = await run({ root, platform }); + /** output 是 Codex 主 Plugin 根。 */ + const output = path.join(root, 'dist/codex/plugin'); + + expect(report.success, JSON.stringify(report.diagnostics, null, 2)).toBe(true); + expect(report.committed).toBe(true); + expect(report.compatibility).toEqual(expect.arrayContaining([ + expect.objectContaining({ subject: 'skill:review', capability: 'component', level: 'native' }), + expect.objectContaining({ + subject: 'command:release', capability: 'component', level: 'transform', + transformation: 'explicit-skill:release-tools-release', + }), + expect.objectContaining({ subject: 'runtime:cli', capability: 'node20-esm', level: 'native' }), + ])); + expect(report.metadata).toContainEqual(expect.objectContaining({ + field: 'displayName', disposition: 'emitted', + })); + await expectGolden(path.join(output, PLUGIN_MANIFEST_PATH), PLUGIN_MANIFEST_PATH); + await expectGolden(path.join(output, 'skills/review/SKILL.md'), 'skills/review/SKILL.md'); + await expectGolden(path.join(output, 'skills/review/agents/openai.yaml'), 'skills/review/agents/openai.yaml'); + await expectGolden(path.join(output, 'skills/release-tools-release/SKILL.md'), 'skills/release-tools-release/SKILL.md'); + await expectGolden(path.join(output, 'skills/release-tools-release/agents/openai.yaml'), 'skills/release-tools-release/agents/openai.yaml'); + await expect(fs.access(path.join(output, 'skills/command-release/SKILL.md'))).rejects.toThrow(); + await expect(fs.readFile(path.join(output, 'skills/review/references/checklist.md'), 'utf8')).resolves.toBe('Review checklist.\n'); + await expect(fs.readFile(path.join(output, 'runtime/cli/main.mjs'), 'utf8')).resolves.toContain('runtime-ready'); + expect(report.packages[0]?.assets.find(asset => asset.path === 'runtime/cli/main.mjs')).toMatchObject({ + owner: 'framework:node-runtime', mode: 0o755, origin: { type: 'compile', profile: 'portable-node' }, + }); + }); + + it('rejects native/generated and generated/generated Skill namespace collisions before Package creation', async () => { + /** nativeRoot 让 native Skill 占用默认 generated Command ID。 */ + const nativeRoot = await temporaryProject(); + await fs.mkdir(path.join(nativeRoot, 'src/commands'), { recursive: true }); + await fs.mkdir(path.join(nativeRoot, 'src/skills/release-tools-release'), { recursive: true }); + await fs.writeFile(path.join(nativeRoot, 'src/commands/release.md'), '---\ndescription: Release.\n---\nRelease.\n'); + await fs.writeFile(path.join(nativeRoot, 'src/skills/release-tools-release/SKILL.md'), + '---\ndescription: Existing Skill.\n---\nExisting.\n'); + /** nativeCollision 必须在 Asset 签发和 Package finalization 前失败。 */ + const nativeCollision = await run({ root: nativeRoot, command: 'validate', commit: false }); + expect(nativeCollision.success).toBe(false); + expect(nativeCollision.packages).toEqual([]); + expect(nativeCollision.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CODEX_GENERATED_SKILL_ID_COLLISION', phase: 'package', + })); + + /** generatedRoot 让 Agent fallback 与 native Skill 占用同一固定 agent 前缀 ID。 */ + const generatedRoot = await temporaryProject(); + await fs.mkdir(path.join(generatedRoot, 'src/agents'), { recursive: true }); + await fs.mkdir(path.join(generatedRoot, 'src/skills/agent-reviewer'), { recursive: true }); + await fs.writeFile(path.join(generatedRoot, 'src/agents/reviewer.md'), '---\ndescription: Review.\n---\nReview.\n'); + await fs.writeFile(path.join(generatedRoot, 'src/skills/agent-reviewer/SKILL.md'), + '---\ndescription: Existing Skill.\n---\nExisting.\n'); + /** generatedCollision 使用与 native/Command 相同的命名空间检查。 */ + const generatedCollision = await run({ root: generatedRoot, command: 'validate', commit: false }); + expect(generatedCollision.success).toBe(false); + expect(generatedCollision.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CODEX_GENERATED_SKILL_ID_COLLISION', phase: 'package', + })); + }); + + it('reports actual argumentHint loss and Agent fallback through final strictness', async () => { + /** commandRoot 只声明一个存在 UI 损失的 argumentHint。 */ + const commandRoot = await temporaryProject(); + await fs.mkdir(path.join(commandRoot, 'src/commands'), { recursive: true }); + await fs.writeFile(path.join(commandRoot, 'src/commands/deploy.md'), `--- +description: Deploy an environment. +argumentHint: +--- +Deploy {{arguments}}. +`); + /** commandReport 应保留完整降级 tuple 并由 strict 阻止成功。 */ + const commandReport = await run({ root: commandRoot, command: 'validate', commit: false }); + expect(commandReport.success).toBe(false); + expect(commandReport.compatibility).toContainEqual(expect.objectContaining({ + subject: 'command:deploy', capability: 'argument-hint', level: 'degraded', + })); + expect(commandReport.diagnostics).toContainEqual(expect.objectContaining({ code: 'COMPATIBILITY_STRICT_FAILURE' })); + + /** agentRoot 只包含 Codex 无法原生注册的 Agent。 */ + const agentRoot = await temporaryProject(); + await fs.mkdir(path.join(agentRoot, 'src/agents'), { recursive: true }); + await fs.writeFile(path.join(agentRoot, 'src/agents/reviewer.md'), `--- +description: Review changes. +model: capable +capabilities: [filesystem:read, search] +--- +Review. +`); + /** strictReport 证明降级先进入报告再执行 strict。 */ + const strictReport = await run({ root: agentRoot, command: 'validate', commit: false }); + /** relaxedReport 允许交付 guidance-only Skill。 */ + const relaxedReport = await run({ root: agentRoot, platform: codex({ strict: false }) }); + expect(strictReport.success).toBe(false); + expect(strictReport.compatibility).toContainEqual(expect.objectContaining({ + subject: 'agent:reviewer', capability: 'component', level: 'degraded', + })); + expect(relaxedReport.success, JSON.stringify(relaxedReport.diagnostics, null, 2)).toBe(true); + await expect(fs.readFile(path.join(agentRoot, 'dist/codex/plugin/skills/agent-reviewer/SKILL.md'), 'utf8')) + .resolves.toContain('Intended model class: capable.'); + }); + + it('explicitly rejects non-empty private Component contributions', async () => { + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/skills/host'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/skills/host/SKILL.md'), '---\ndescription: Host.\n---\nHost.\n'); + const report = await run({ + root, command: 'validate', commit: false, extensions: [unsupportedComponentContribution()], + }); + + expect(report.success).toBe(false); + expect(report.packages).toEqual([]); + expect(report.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CODEX_COMPONENT_CONTRIBUTION_UNSUPPORTED', phase: 'finalize', platform: 'codex', + })); + }); + + it('lets Hooks and MCP Extensions use only declared add-only points and validates final wire data', async () => { + /** root 需要至少一个合法 Skill 作为 Extension host。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/skills/host'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/skills/host/SKILL.md'), '---\ndescription: Host.\n---\nHost.\n'); + /** hooks 提供最终平台 validator 可接受的 wire schema。 */ + const hooks = contributionExtension({ + id: 'hooks-fixture', field: 'hooks', value: './hooks/hooks.json', path: 'hooks/hooks.json', + bytes: '{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"node hook.mjs"}]}]}}\n', + }); + /** mcp 提供固定 manifest 引用目标。 */ + const mcp = contributionExtension({ + id: 'mcp-fixture', field: 'mcpServers', value: './.mcp.json', path: '.mcp.json', + bytes: '{"docs":{"url":"https://developers.openai.com/mcp"}}\n', + }); + /** valid 验证集中合并和最终引用检查。 */ + const valid = await run({ root, extensions: [hooks, mcp] }); + /** manifest 是 Core codec 序列化后的最终 Document。 */ + const manifest = JSON.parse(await fs.readFile(path.join(root, 'dist/codex/plugin', PLUGIN_MANIFEST_PATH), 'utf8')); + expect(valid.success, JSON.stringify(valid.diagnostics, null, 2)).toBe(true); + expect(manifest).toMatchObject({ hooks: './hooks/hooks.json', mcpServers: './.mcp.json' }); + + /** invalidRoot 隔离最终 Hook timeout protocol 错误。 */ + const invalidRoot = await temporaryProject(); + await fs.mkdir(path.join(invalidRoot, 'src/skills/host'), { recursive: true }); + await fs.writeFile(path.join(invalidRoot, 'src/skills/host/SKILL.md'), '---\ndescription: Host.\n---\nHost.\n'); + /** invalidHook 的 SessionEnd timeout 超出 Codex 三秒上限。 */ + const invalidHook = contributionExtension({ + id: 'invalid-hook', field: 'hooks', value: './hooks/hooks.json', path: 'hooks/hooks.json', + bytes: '{"hooks":{"SessionEnd":[{"hooks":[{"type":"command","command":"node hook.mjs","timeout":4}]}]}}\n', + }); + /** invalid 必须在 candidate validator 阶段失败。 */ + const invalid = await run({ root: invalidRoot, command: 'validate', extensions: [invalidHook], commit: false }); + expect(invalid.success).toBe(false); + expect(invalid.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CODEX_HOOK_TIMEOUT_LIMIT', phase: 'platform-validate', + })); + }); + + it('creates a policy-aware Marketplace by inheriting validated primary AssetRefs byte-for-byte', async () => { + /** root 包含 Component、Public 和 Runtime 三类 inherited Asset。 */ + const root = await temporaryProject(); + await writeSupportedProject(root); + /** platform 配置 Marketplace 安装策略和缺省分类。 */ + const platform = codex({ + interface: { category: 'Developer Tools' }, + marketplace: { policy: { installation: 'INSTALLED_BY_DEFAULT' } }, + }); + /** first 提供确定性和继承报告基线。 */ + const first = await run({ root, platform }); + /** primary 是已通过完整 Codex validator 的主 Package。 */ + const primary = first.packages.find(unit => unit.id === 'plugin')!; + /** distribution 应复用 primary 的每个 AssetRef。 */ + const distribution = first.packages.find(unit => unit.id === 'marketplace')!; + /** second 验证同输入的完整事务替换保持确定性。 */ + const second = await run({ root, platform }); + /** marketplaceRoot 是最终分发根。 */ + const marketplaceRoot = path.join(root, 'dist/codex/marketplace'); + + expect(first.success, JSON.stringify(first.diagnostics, null, 2)).toBe(true); + expect(second.success, JSON.stringify(second.diagnostics, null, 2)).toBe(true); + await expectGolden(path.join(marketplaceRoot, MARKETPLACE_MANIFEST_PATH), MARKETPLACE_MANIFEST_PATH); + await expect(fs.readFile(path.join(marketplaceRoot, PLUGIN_MANIFEST_PATH))).resolves.toEqual( + await fs.readFile(path.join(root, 'dist/codex/plugin', PLUGIN_MANIFEST_PATH)), + ); + for (const source of primary.assets) { + expect(distribution.assets.find(asset => asset.path === source.path)).toMatchObject({ + owner: source.owner, mode: source.mode, sha256: source.sha256, origin: source.origin, + }); + } + expect(second.packages.find(unit => unit.id === 'marketplace')?.assets).toEqual(distribution.assets); + }); + + it('validates factory and Component fields without ID strategy or raw schema escape hatches', async () => { + expect(() => codex({ raw: true } as never)).toThrow('Unknown Codex Platform option'); + expect(() => codex({ generatedSkillIds: { command: 'plugin-prefixed' } } as never)).toThrow('Unknown Codex Platform option'); + expect(() => codex({ interface: { displayName: 'duplicate' } } as never)).toThrow('Unknown Codex interface option'); + expect(() => codex({ interface: { websiteURL: 'https://user:secret@example.com' } })).toThrow('without credentials'); + expect(() => codex({ marketplace: { policy: { installation: 'UNKNOWN' } } } as never)).toThrow('not supported'); + + /** root 的非法 Skill icon path 必须在 Component validation 阶段失败。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/skills/invalid'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/skills/invalid/SKILL.md'), `--- +description: Invalid platform field fixture. +platforms: + codex: + iconSmall: ../escape.png +--- +Do not build. +`); + /** report 应保留规范 namespace fieldPath。 */ + const report = await run({ root, command: 'validate', commit: false }); + expect(report.success).toBe(false); + expect(report.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CODEX_COMPONENT_FIELD_INVALID', fieldPath: ['platforms', 'codex', 'iconSmall'], + })); + }); + + it('rejects invalid Extension Skill, missing Skill icon, and malformed branding at candidate boundary', async () => { + /** skillRoot 的 Extension 追加无 Frontmatter Skill。 */ + const skillRoot = await temporaryProject(); + await fs.mkdir(path.join(skillRoot, 'src/skills/host'), { recursive: true }); + await fs.writeFile(path.join(skillRoot, 'src/skills/host/SKILL.md'), '---\ndescription: Host.\n---\nHost.\n'); + /** invalidSkill 不占 Document 字段,只追加协议错误的 Skill。 */ + const invalidSkill = contributionExtension({ + id: 'invalid-skill', path: 'skills/invalid-extension/SKILL.md', bytes: 'missing frontmatter\n', + }); + /** skillReport 必须在最终 validator 阶段失败。 */ + const skillReport = await run({ root: skillRoot, command: 'validate', extensions: [invalidSkill], commit: false }); + expect(skillReport.diagnostics).toContainEqual(expect.objectContaining({ code: 'CODEX_SKILL_FRONTMATTER_INVALID' })); + + /** iconRoot 声明安全但不存在的 Skill-local icon。 */ + const iconRoot = await temporaryProject(); + await fs.mkdir(path.join(iconRoot, 'src/skills/icon-test'), { recursive: true }); + await fs.writeFile(path.join(iconRoot, 'src/skills/icon-test/SKILL.md'), `--- +description: Validate Skill metadata assets. +platforms: + codex: + iconSmall: ./assets/missing.png +--- +Validate. +`); + /** iconReport 由最终 Skill metadata 引用检查拒绝。 */ + const iconReport = await run({ root: iconRoot, command: 'validate', commit: false }); + expect(iconReport.diagnostics).toContainEqual(expect.objectContaining({ code: 'CODEX_SKILL_ASSET_MISSING' })); + + /** brandingRoot 包含扩展名和内容都不匹配的公开资源。 */ + const brandingRoot = await temporaryProject(); + await fs.mkdir(path.join(brandingRoot, 'src/skills/branding'), { recursive: true }); + await fs.mkdir(path.join(brandingRoot, 'public/assets'), { recursive: true }); + await fs.writeFile(path.join(brandingRoot, 'src/skills/branding/SKILL.md'), '---\ndescription: Branding.\n---\nBranding.\n'); + await fs.writeFile(path.join(brandingRoot, 'public/assets/not-an-image.bin'), Buffer.from([0, 1, 2, 255])); + /** brandingReport 验证实际候选字节而不是只验证安全路径。 */ + const brandingReport = await run({ + root: brandingRoot, command: 'validate', + platform: codex({ interface: { logo: './assets/not-an-image.bin' } }), commit: false, + }); + expect(brandingReport.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CODEX_BRANDING_IMAGE_FORMAT_UNSUPPORTED', + })); + }); + + it('rejects malformed MCP wire data at the final candidate boundary', async () => { + /** root 没有其他资源,错误只来自 Extension 贡献的最终 MCP 配置。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/skills/host'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/skills/host/SKILL.md'), '---\ndescription: Host fixture.\n---\nHost.\n'); + /** malformed 的 HTTP headers 不是字符串映射,并包含未确认字段。 */ + const malformed = contributionExtension({ + id: 'invalid-mcp', field: 'mcpServers', value: './.mcp.json', path: '.mcp.json', + bytes: '{"docs":{"url":"https://example.com/mcp","http_headers":42,"extra":true}}\n', + }); + /** report 必须保留 Codex 最终候选 validator 的细粒度诊断。 */ + const report = await run({ root, command: 'validate', extensions: [malformed], commit: false }); + + expect(report.success).toBe(false); + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'CODEX_MCP_HEADERS_INVALID', phase: 'platform-validate' }), + expect.objectContaining({ code: 'CODEX_MCP_FIELD_UNKNOWN', phase: 'platform-validate' }), + ])); + }); + + it('strictly rejects malformed SVG XML and dimensions with units', async () => { + /** fixtures 覆盖 XML 未闭合和带单位尺寸两个严格拒绝分支。 */ + const fixtures = [ + ['unclosed.svg', ''], + ['unit-size.svg', ''], + ] as const; + for (const [fileName, source] of fixtures) { + /** root 隔离当前不合法 SVG。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/skills/branding'), { recursive: true }); + await fs.mkdir(path.join(root, 'public/assets'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/skills/branding/SKILL.md'), '---\ndescription: Branding.\n---\nBranding.\n'); + await fs.writeFile(path.join(root, 'public/assets', fileName), source); + /** report 必须由严格 XML/尺寸解析失败。 */ + const report = await run({ + root, command: 'validate', platform: codex({ interface: { logo: `./assets/${fileName}` } }), commit: false, + }); + expect(report.diagnostics).toContainEqual(expect.objectContaining({ code: 'CODEX_BRANDING_IMAGE_DECODE_FAILED' })); + } + }); +}); diff --git a/packages/platforms/codex/tsconfig.json b/packages/platforms/codex/tsconfig.json new file mode 100644 index 0000000..3ae4da2 --- /dev/null +++ b/packages/platforms/codex/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/platforms/codex/tsdown.config.ts b/packages/platforms/codex/tsdown.config.ts new file mode 100644 index 0000000..81730c8 --- /dev/null +++ b/packages/platforms/codex/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsdown'; + +/** Codex Platform 骨架使用统一 Node ESM 与声明输出。 */ +export default defineConfig({ + entry: ['./src/index.ts'], + format: ['esm'], + platform: 'node', + target: 'node20', + dts: { generator: 'oxc' }, + clean: true, + sourcemap: false, + deps: { neverBundle: ['@tokenroll/acplugin'] }, +}); diff --git a/packages/platforms/codex/vitest.config.ts b/packages/platforms/codex/vitest.config.ts new file mode 100644 index 0000000..dfa1f49 --- /dev/null +++ b/packages/platforms/codex/vitest.config.ts @@ -0,0 +1,22 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +/** Codex 单测让公开主包与私有 Core 共享同一源码品牌实例。 */ +export default defineConfig({ + resolve: { + alias: [ + { + find: /^@tokenroll\/acplugin\/sdk$/, + replacement: fileURLToPath(new URL('../../acplugin/src/sdk.ts', import.meta.url)), + }, + { + find: /^@tokenroll\/acplugin$/, + replacement: fileURLToPath(new URL('../../acplugin/src/index.ts', import.meta.url)), + }, + { + find: /^@acplugin\/core$/, + replacement: fileURLToPath(new URL('../../core/src/index.ts', import.meta.url)), + }, + ], + }, +}); diff --git a/packages/platforms/cursor/CHANGELOG.md b/packages/platforms/cursor/CHANGELOG.md new file mode 100644 index 0000000..705017d --- /dev/null +++ b/packages/platforms/cursor/CHANGELOG.md @@ -0,0 +1,26 @@ +# @tokenroll/acplugin-platform-cursor + +## 0.0.3-beta + +### Major Changes + +- Add opaque, subject-bound Platform Component Contributions to the trusted Integration SDK. Core now transports strict JSON payloads and records scoped contributor provenance in BuildReport schema version 3 without acquiring Platform-specific Agent or target-format knowledge. + + Claude Code, Cursor, and OpenCode expose and render their own native Agent contribution payloads during Platform finalization. Codex, Antigravity, and Pi explicitly reject non-empty private component contributions rather than silently dropping them or generating fallback Skills. + + Harden `AssetService.fromBytes()` to accept only exact data-object inputs, exact generated-origin fields, and `string | Uint8Array` bytes so third-party Integrations cannot rely on accessor, hidden-field, or array-like coercion. + +### Patch Changes + +- Updated dependencies + - @tokenroll/acplugin@0.0.3-beta + +## 0.0.2-beta + +### Major Changes + +- 889da32: Rewrite the Cursor Platform around the Package API, Core-owned Document codecs, native Command/Skill/Agent Assets, add-only Hooks/MCP extension points, final candidate validation, and explicit unsupported Node Runtime compatibility. + +### Patch Changes + +- Updated peer dependency on `@tokenroll/acplugin` to `^0.0.2-beta`. diff --git a/packages/platforms/cursor/LICENSE b/packages/platforms/cursor/LICENSE new file mode 100644 index 0000000..9113de7 --- /dev/null +++ b/packages/platforms/cursor/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 TokenRollAI + +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/packages/platforms/cursor/README.md b/packages/platforms/cursor/README.md new file mode 100644 index 0000000..9dd2ca7 --- /dev/null +++ b/packages/platforms/cursor/README.md @@ -0,0 +1,27 @@ +# @tokenroll/acplugin-platform-cursor + +Cursor Platform package for `@tokenroll/acplugin`. + +```bash +pnpm add -D @tokenroll/acplugin @tokenroll/acplugin-platform-cursor +``` + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import cursor from '@tokenroll/acplugin-platform-cursor'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [cursor()], +}); +``` + +The package also exports the named `cursor` factory, its option types, `CursorPackageComponent`, `CursorNativeAgentComponent`, `PLATFORM_ID`, and `PLATFORM_API_VERSION`. + +`CursorPackageComponent` is for trusted Extension contributors that need Cursor-native private delivery. Cursor validates and renders it during Package finalization; it does not permit raw Plugin Manifest patches. + +## License + +MIT diff --git a/packages/platforms/cursor/package.json b/packages/platforms/cursor/package.json new file mode 100644 index 0000000..996cd2d --- /dev/null +++ b/packages/platforms/cursor/package.json @@ -0,0 +1,29 @@ +{ + "name": "@tokenroll/acplugin-platform-cursor", + "version": "0.0.3-beta", + "description": "Cursor Platform integration for acplugin.", + "type": "module", + "license": "MIT", + "homepage": "https://github.com/TokenRollAI/acplugin#cursor-platform", + "repository": { "type": "git", "url": "git+https://github.com/TokenRollAI/acplugin.git", "directory": "packages/platforms/cursor" }, + "bugs": { "url": "https://github.com/TokenRollAI/acplugin/issues" }, + "sideEffects": false, + "engines": { "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, + "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" } }, + "files": ["dist", "README.md", "LICENSE"], + "publishConfig": { "access": "public" }, + "scripts": { + "build": "tsdown", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc -p tsconfig.json" + }, + "peerDependencies": { "@tokenroll/acplugin": "workspace:^" }, + "devDependencies": { + "@acplugin/core": "workspace:*", + "@tokenroll/acplugin": "workspace:^", + "@types/node": "catalog:", + "tsdown": "catalog:", + "@typescript/native": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/platforms/cursor/src/index.ts b/packages/platforms/cursor/src/index.ts new file mode 100644 index 0000000..492f809 --- /dev/null +++ b/packages/platforms/cursor/src/index.ts @@ -0,0 +1,180 @@ +import { + definePlatform, + type AcpluginPlatform, + type ContributedPackageComponent, + type JsonObject, +} from '@tokenroll/acplugin/sdk'; +import { + createCursorComponents, + cursorNativeAgentDocument, + renderCursorAgent, + validateCursorComponent, +} from './package/components.js'; +import { createPluginDocument, validatePlatformOptions } from './package/manifest.js'; +import type { CursorNativeAgentComponent, CursorPackageComponent, CursorPlatformOptions } from './types.js'; +import { validateCursorPackage } from './package/validator.js'; + +export type { CursorNativeAgentComponent, CursorPackageComponent, CursorPlatformOptions } from './types.js'; + +/** Cursor Platform 的稳定开放 ID。 */ +export const PLATFORM_ID = 'cursor' as const; + +/** Cursor Platform 实现的 Core API 版本。 */ +export const PLATFORM_API_VERSION = '1' as const; + +const STABLE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + +/** Platform-owned payload errors remain distinguishable from unexpected implementation failures. */ +class CursorComponentContributionError extends Error { + constructor( + readonly category: 'invalid' | 'collision', + message: string, + ) { + super(message); + this.name = 'CursorComponentContributionError'; + } +} + +/** 解析 Cursor 自己拥有的 Native Agent payload schema。 */ +function nativeAgent(value: unknown): CursorNativeAgentComponent { + if (typeof value !== 'object' || value === null || Array.isArray(value) + || (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) + || Object.getOwnPropertySymbols(value).length > 0) { + throw new TypeError('Cursor Platform Component must be a plain object.'); + } + const allowed = new Set(['kind', 'id', 'description', 'body', 'readonly']); + const fields = Object.getOwnPropertyDescriptors(value); + for (const [field, descriptor] of Object.entries(fields)) { + if (!allowed.has(field)) + throw new TypeError(`Unknown Cursor Platform Component field "${field}".`); + if (!('value' in descriptor) || descriptor.enumerable !== true) + throw new TypeError(`Cursor Platform Component.${field} must be an enumerable data property.`); + } + const input = value as Record; + if (input.kind !== 'native-agent') + throw new TypeError('Cursor Platform Component kind must be native-agent.'); + if (typeof input.id !== 'string' || !STABLE_ID.test(input.id)) + throw new TypeError('Cursor Platform Component id must use lowercase kebab-case.'); + if (typeof input.description !== 'string' || input.description.trim().length === 0 || /[\r\n\t\0]/u.test(input.description)) + throw new TypeError('Cursor Platform Component description must be a non-empty stable single-line string.'); + if (typeof input.body !== 'string' || input.body.trim().length === 0) + throw new TypeError('Cursor Platform Component body must be non-empty.'); + if (input.readonly !== undefined && typeof input.readonly !== 'boolean') + throw new TypeError('Cursor Platform Component readonly must be boolean.'); + return input as CursorNativeAgentComponent; +} + +/** Cursor Agent namespace follows target filesystem case/NFC collision semantics. */ +function agentCollisionKey(id: string): string { + return id.normalize('NFC').toLowerCase(); +} + +/** 将 Cursor private payload 解析、校验并渲染为 Platform-owned Agent Assets。 */ +async function contributedAgents( + components: readonly ContributedPackageComponent[], + canonicalIds: readonly string[], + assets: import('@tokenroll/acplugin/sdk').FinalizationAssetService, +): Promise<{ readonly assets: readonly import('@tokenroll/acplugin/sdk').PackageAssetInput[]; readonly origins: readonly import('@tokenroll/acplugin/sdk').PackageComponentOrigin[] }> { + const occupied = new Map(canonicalIds.map(id => [agentCollisionKey(id), `canonical Agent "${id}"`])); + let parsed: readonly { readonly component: CursorNativeAgentComponent; readonly origin: import('@tokenroll/acplugin/sdk').PackageComponentOrigin }[]; + try { + parsed = components.map(component => Object.freeze({ component: nativeAgent(component.value), origin: component.origin })); + } catch (error) { + if (!(error instanceof TypeError)) + throw error; + throw new CursorComponentContributionError('invalid', error.message); + } + for (const { component } of parsed) { + const key = agentCollisionKey(component.id); + const existing = occupied.get(key); + if (existing !== undefined) { + throw new CursorComponentContributionError( + 'collision', + 'Cursor Native Agent "' + component.id + '" collides with ' + existing + '.', + ); + } + occupied.set(key, `contributed Native Agent "${component.id}"`); + } + const output: import('@tokenroll/acplugin/sdk').PackageAssetInput[] = []; + const origins: import('@tokenroll/acplugin/sdk').PackageComponentOrigin[] = []; + for (const { component, origin } of [...parsed].sort((left, right) => left.component.id < right.component.id ? -1 : left.component.id > right.component.id ? 1 : 0)) { + const asset = await assets.fromBytes({ + bytes: renderCursorAgent(cursorNativeAgentDocument(component)), + origin: { operation: 'platform-component-agent', subjects: [origin.subject], componentOrigins: [origin] }, + }); + output.push(Object.freeze({ path: `agents/${component.id}.md`, asset })); + origins.push(origin); + } + return Object.freeze({ assets: Object.freeze(output), origins: Object.freeze(origins) }); +} + +/** 创建只通过 Package API 交付 Cursor Plugin 的 Platform。 */ +export function cursor(options: CursorPlatformOptions = {}): AcpluginPlatform { + validatePlatformOptions(options); + /** strict 由 Core 解释,其余选项复制、深冻后进入 Platform Session。 */ + const { strict, ...platformOptions } = options; + return definePlatform({ + id: PLATFORM_ID, + apiVersion: PLATFORM_API_VERSION, + deliveryType: 'plugin', + ...(strict === undefined ? {} : { strict }), + options: platformOptions as unknown as JsonObject, + /** Cursor 不声明 Node Runtime 能力,Core 将对存在的 Runtime 显式报告 unsupported。 */ + createSession({ options: sessionOptions }) { + return { + validateComponent: validateCursorComponent, + /** base Package 包含原生 Components 和唯一结构化 Manifest。 */ + async createPackage({ project, assets }) { + /** components 全部通过当前 Platform owner 的 Asset Service 签发。 */ + const components = await createCursorComponents(project, assets); + /** manifest 由 Core codec 负责序列化,Extension 只能填写声明点。 */ + const manifest = createPluginDocument({ + project, + options: sessionOptions, + components: { commands: project.commands.length > 0, skills: project.skills.length > 0 }, + }); + return { + documents: [manifest.document], + assets: components.assets, + compatibility: components.compatibility, + metadata: manifest.metadata, + }; + }, + /** Cursor 完整拥有 Native Agent render、Manifest glob 和 collision policy。 */ + async finalizePackage({ project, package: mergedPackage, assets, diagnostics }) { + let contributed: Awaited>; + try { + contributed = await contributedAgents(mergedPackage.components, project.agents.map(agent => agent.id), assets); + } catch (error) { + if (!(error instanceof CursorComponentContributionError)) + throw error; + diagnostics.report({ + code: error.category === 'collision' + ? 'CURSOR_COMPONENT_CONTRIBUTION_COLLISION' + : 'CURSOR_COMPONENT_CONTRIBUTION_INVALID', + severity: 'error', + message: error.message, + }); + return { id: 'plugin', type: 'plugin' as const }; + } + return { + id: 'plugin', + type: 'plugin' as const, + assets: contributed.assets, + ...(project.agents.length + contributed.assets.length === 0 + ? {} + : { + documentFields: [{ + document: 'plugin-manifest', path: ['agents'], value: './agents/*.md', + ...(contributed.origins.length === 0 ? {} : { componentOrigins: contributed.origins }), + }], + }), + }; + }, + validatePackage: validateCursorPackage, + }; + }, + }); +} + +export default cursor; diff --git a/packages/platforms/cursor/src/package/components.ts b/packages/platforms/cursor/src/package/components.ts new file mode 100644 index 0000000..5566a9b --- /dev/null +++ b/packages/platforms/cursor/src/package/components.ts @@ -0,0 +1,165 @@ +import { + markdownWithFrontmatter, + type AgentCapability, + type AssetService, + type CanonicalProject, + type CompatibilityInput, + type PackageAssetInput, + type PlatformComponentValidationContext, +} from '@tokenroll/acplugin/sdk'; +import type { CursorNativeAgentComponent } from '../types.js'; + +/** Cursor 当前不开放任何未经独立 Schema 验证的 Component 专属字段。 */ +const COMPONENT_FIELDS = new Set(); + +/** Cursor base Package 的 Component 转换结果。 */ +export interface CursorComponentPackage { + readonly assets: readonly PackageAssetInput[]; + readonly compatibility: readonly CompatibilityInput[]; +} + +/** 校验 Cursor Component namespace,不允许 raw Frontmatter 逃逸。 */ +export function validateCursorComponent(context: PlatformComponentValidationContext): void { + /** fields 是 Scanner 已复制冻结的 Cursor namespace。 */ + const fields = context.component.platforms.cursor ?? {}; + for (const field of Object.keys(fields)) { + if (!COMPONENT_FIELDS.has(field)) { + context.diagnostics.report({ + code: 'CURSOR_COMPONENT_FIELD_UNKNOWN', + severity: 'error', + message: `Unknown Cursor ${context.component.kind} field "${field}".`, + fieldPath: ['platforms', 'cursor', field], + }); + } + } +} + +/** @returns Agent portable capabilities 是否能精确收敛为 Cursor readonly。 */ +function isReadOnly(capabilities: readonly AgentCapability[]): boolean { + return capabilities.every(capability => capability === 'filesystem:read' || capability === 'search'); +} + +/** Cursor Native Subagent renderer 的已验证 Platform-owned 输入。 */ +export interface CursorAgentDocumentInput { + readonly id: string; + readonly description: string; + readonly body: string; + readonly readonly?: boolean; +} + +/** 以 Cursor 自己的 Subagent frontmatter 渲染 Agent。 */ +export function renderCursorAgent(input: CursorAgentDocumentInput): string { + return markdownWithFrontmatter({ + name: input.id, + description: input.description, + ...(input.readonly === true ? { readonly: true } : {}), + }, input.body); +} + +/** 将 Cursor 私有 Component 映射为 Cursor Agent renderer 输入。 */ +export function cursorNativeAgentDocument(component: CursorNativeAgentComponent): CursorAgentDocumentInput { + return Object.freeze({ + id: component.id, + description: component.description, + body: component.body, + ...(component.readonly === undefined ? {} : { readonly: component.readonly }), + }); +} + +/** 把 canonical Commands、Skills 与 Agents 转换为 Cursor 原生 Assets。 */ +export async function createCursorComponents( + project: CanonicalProject, + assets: AssetService, +): Promise { + /** output 只包含 Platform 自有 bytes 和 Core 授权的 Skill auxiliary refs。 */ + const output: PackageAssetInput[] = []; + /** compatibility 对每个 Component 精确覆盖 component tuple 与实际语义差异。 */ + const compatibility: CompatibilityInput[] = []; + for (const command of project.commands) { + /** Command Markdown 使用 Cursor 原生参数占位符。 */ + const asset = await assets.fromBytes({ + bytes: markdownWithFrontmatter({ description: command.description }, command.body.replaceAll('{{arguments}}', '$ARGUMENTS')), + origin: { operation: 'component-command', subjects: [`command:${command.id}`] }, + }); + output.push(Object.freeze({ path: `commands/${command.id}.md`, asset })); + compatibility.push(Object.freeze({ + subject: `command:${command.id}`, + capability: 'component', + level: 'native', + reason: 'Cursor supports native Plugin Commands and the $ARGUMENTS placeholder.', + })); + if (command.argumentHint !== undefined) { + compatibility.push(Object.freeze({ + subject: `command:${command.id}`, + capability: 'argument-hint', + level: 'degraded', + transformation: 'argument-hint-omitted', + reason: 'Cursor Command metadata has no verified argument hint field.', + })); + } + } + for (const skill of project.skills) { + /** Skill 主文档保留 Cursor 支持的 model invocation policy。 */ + const asset = await assets.fromBytes({ + bytes: markdownWithFrontmatter({ + 'name': skill.id, + 'description': skill.description, + 'disable-model-invocation': !skill.invocation.model, + }, skill.body), + origin: { operation: 'component-skill', subjects: [`skill:${skill.id}`] }, + }); + output.push(Object.freeze({ path: `skills/${skill.id}/SKILL.md`, asset })); + for (const auxiliary of skill.auxiliaryFiles) + output.push(Object.freeze({ path: `skills/${skill.id}/${auxiliary.path}`, asset: auxiliary.asset })); + compatibility.push(Object.freeze({ + subject: `skill:${skill.id}`, + capability: 'component', + level: 'native', + reason: 'Cursor supports native Plugin Agent Skills.', + })); + if (!skill.invocation.user) { + compatibility.push(Object.freeze({ + subject: `skill:${skill.id}`, + capability: 'invocation.user', + level: 'degraded', + transformation: 'explicit-invocation-remains', + reason: 'Cursor Skill metadata cannot disable explicit user invocation.', + })); + } + } + for (const agent of project.agents) { + /** readonly 只在全部 portable capabilities 都可精确表达时生成。 */ + const readonly = isReadOnly(agent.capabilities); + /** Agent Markdown 使用 Cursor 原生 Subagent schema。 */ + const asset = await assets.fromBytes({ + bytes: renderCursorAgent({ id: agent.id, description: agent.description, body: agent.body, ...(readonly ? { readonly: true } : {}) }), + origin: { operation: 'component-agent', subjects: [`agent:${agent.id}`] }, + }); + output.push(Object.freeze({ path: `agents/${agent.id}.md`, asset })); + compatibility.push(Object.freeze({ + subject: `agent:${agent.id}`, + capability: 'component', + level: 'native', + reason: 'Cursor supports native Plugin Subagents.', + })); + if (agent.model !== 'inherit') { + compatibility.push(Object.freeze({ + subject: `agent:${agent.id}`, + capability: 'agent.model', + level: 'degraded', + transformation: 'platform-default-model', + reason: 'Cursor has no stable mapping for canonical abstract model classes.', + })); + } + if (agent.capabilities.length > 0 && !readonly) { + compatibility.push(Object.freeze({ + subject: `agent:${agent.id}`, + capability: 'agent.capabilities', + level: 'degraded', + transformation: 'capability-boundary-omitted', + reason: 'Cursor can express readonly but not every canonical capability combination.', + })); + } + } + return Object.freeze({ assets: Object.freeze(output), compatibility: Object.freeze(compatibility) }); +} diff --git a/packages/platforms/cursor/src/package/manifest.ts b/packages/platforms/cursor/src/package/manifest.ts new file mode 100644 index 0000000..c5105fe --- /dev/null +++ b/packages/platforms/cursor/src/package/manifest.ts @@ -0,0 +1,146 @@ +import type { + CanonicalProject, + JsonObject, + MetadataDispositionInput, + PackageDocumentInput, + PluginMetadata, +} from '@tokenroll/acplugin/sdk'; +import type { CursorPlatformOptions, CursorPluginManifest } from '../types.js'; + +/** Cursor Plugin 清单的稳定逻辑 Document ID。 */ +export const PLUGIN_MANIFEST_ID = 'plugin-manifest'; + +/** Cursor Plugin 清单相对于安装根的固定路径。 */ +export const PLUGIN_MANIFEST_PATH = '.cursor-plugin/plugin.json'; + +/** Cursor 与 Core 共同采用的完整语义版本规则。 */ +export const SEMVER_PATTERN: RegExp = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*)?$/u; + +/** @returns 候选是否为非空字符串。 */ +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +/** 校验 Cursor Platform 工厂只接收官方 Schema 对应字段。 */ +export function validatePlatformOptions(options: CursorPlatformOptions): void { + /** allowed 是 Cursor 工厂公开且经验证的精确字段集合。 */ + const allowed = new Set(['strict', 'publisher', 'logo', 'category', 'tags', 'minClientVersions']); + for (const field of Object.keys(options)) { + if (!allowed.has(field)) + throw new TypeError(`Unknown Cursor Platform option "${field}".`); + } + if (options.strict !== undefined && typeof options.strict !== 'boolean') + throw new TypeError('Cursor strict must be a boolean.'); + for (const field of ['publisher', 'logo', 'category'] as const) { + if (options[field] !== undefined && !isNonEmptyString(options[field])) + throw new TypeError(`Cursor ${field} must be a non-empty string.`); + } + if (options.tags !== undefined + && (!Array.isArray(options.tags) + || options.tags.some(tag => !isNonEmptyString(tag)) + || new Set(options.tags).size !== options.tags.length)) { + throw new TypeError('Cursor tags must contain unique non-empty strings.'); + } + if (options.minClientVersions !== undefined) { + if (options.minClientVersions === null || typeof options.minClientVersions !== 'object' + || Array.isArray(options.minClientVersions) || Object.keys(options.minClientVersions).length === 0) { + throw new TypeError('Cursor minClientVersions must be a non-empty object.'); + } + for (const [client, version] of Object.entries(options.minClientVersions)) { + if (!isNonEmptyString(client) || typeof version !== 'string' || !SEMVER_PATTERN.test(version)) + throw new TypeError('Cursor minClientVersions must map non-empty client IDs to semantic versions.'); + } + } +} + +/** @returns 统一元数据和 Cursor 选项组成的官方 Plugin Manifest。 */ +function pluginManifest( + project: CanonicalProject, + options: Readonly, + components: { readonly commands: boolean; readonly skills: boolean }, +): CursorPluginManifest { + /** metadata 已由 Core config resolver 完整验证。 */ + const metadata = project.metadata; + return { + name: metadata.name, + version: metadata.version, + description: metadata.description, + ...(metadata.displayName === undefined ? {} : { displayName: metadata.displayName }), + ...(metadata.author === undefined + ? {} + : { + author: { + name: metadata.author.name, + ...(metadata.author.email === undefined ? {} : { email: metadata.author.email }), + }, + }), + ...(metadata.homepage === undefined ? {} : { homepage: metadata.homepage }), + ...(metadata.repository === undefined ? {} : { repository: metadata.repository }), + ...(metadata.license === undefined ? {} : { license: metadata.license }), + ...(metadata.keywords.length === 0 ? {} : { keywords: metadata.keywords }), + ...(options.publisher === undefined ? {} : { publisher: options.publisher as string }), + ...(options.logo === undefined ? {} : { logo: options.logo as string }), + ...(options.category === undefined ? {} : { category: options.category as string }), + ...(options.tags === undefined ? {} : { tags: options.tags as readonly string[] }), + ...(options.minClientVersions === undefined + ? {} + : { + minClientVersions: options.minClientVersions as Readonly>, + }), + ...(components.commands ? { commands: './commands/*.md' } : {}), + ...(components.skills ? { skills: './skills/*/SKILL.md' } : {}), + }; +} + +/** @returns 当前工程实际 metadata 的完整 emitted/omitted disposition。 */ +function metadataDispositions(metadata: PluginMetadata): readonly MetadataDispositionInput[] { + /** outputs 精确对应 Core 使用的字段粒度和最终 Manifest 位置。 */ + const outputs: [string, string | undefined][] = [ + ['name', `${PLUGIN_MANIFEST_PATH}/name`], + ['version', `${PLUGIN_MANIFEST_PATH}/version`], + ['description', `${PLUGIN_MANIFEST_PATH}/description`], + ]; + for (const field of ['displayName', 'homepage', 'repository', 'license'] as const) { + if (metadata[field] !== undefined) + outputs.push([field, `${PLUGIN_MANIFEST_PATH}/${field}`]); + } + if (metadata.author !== undefined) { + outputs.push(['author.name', `${PLUGIN_MANIFEST_PATH}/author/name`]); + if (metadata.author.email !== undefined) + outputs.push(['author.email', `${PLUGIN_MANIFEST_PATH}/author/email`]); + if (metadata.author.url !== undefined) + outputs.push(['author.url', undefined]); + } + if (metadata.keywords.length > 0) + outputs.push(['keywords', `${PLUGIN_MANIFEST_PATH}/keywords`]); + return Object.freeze(outputs.map(([field, output]) => Object.freeze({ + field, + disposition: output === undefined ? 'omitted' as const : 'emitted' as const, + ...(output === undefined ? {} : { output }), + reason: output === undefined + ? 'Cursor plugin.json author accepts only name and email.' + : `Cursor plugin.json supports ${field}.`, + }))); +} + +/** 创建由 Core codec 序列化且只开放 Hooks/MCP 的 Cursor Plugin Document。 */ +export function createPluginDocument(input: { + readonly project: CanonicalProject; + readonly options: Readonly; + readonly components: { readonly commands: boolean; readonly skills: boolean }; +}): { readonly document: PackageDocumentInput; readonly metadata: readonly MetadataDispositionInput[] } { + /** document 是 Cursor base Package 的唯一结构化清单。 */ + const document: PackageDocumentInput = Object.freeze({ + id: PLUGIN_MANIFEST_ID, + path: PLUGIN_MANIFEST_PATH, + format: 'json', + value: pluginManifest(input.project, input.options, input.components) as unknown as JsonObject, + extensionPoints: Object.freeze([ + Object.freeze(['hooks'] as const), + Object.freeze(['mcpServers'] as const), + ]), + /** 合并私有 Component 后由 Cursor Platform 自己决定是否声明 Agents glob。 */ + finalizationPoints: Object.freeze([Object.freeze(['agents'] as const)]), + }); + return Object.freeze({ document, metadata: metadataDispositions(input.project.metadata) }); +} diff --git a/packages/platforms/cursor/src/package/validator.ts b/packages/platforms/cursor/src/package/validator.ts new file mode 100644 index 0000000..7338556 --- /dev/null +++ b/packages/platforms/cursor/src/package/validator.ts @@ -0,0 +1,371 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { JsonValue, ValidatePackageContext } from '@tokenroll/acplugin/sdk'; +import { PLUGIN_MANIFEST_PATH, SEMVER_PATTERN } from './manifest.js'; + +/** Cursor validator 只消费 SDK 的最终 Package candidate Context。 */ +type PlatformValidateContext = ValidatePackageContext; + +/** Cursor 官方 Schema 当前允许的根字段。 */ +const MANIFEST_FIELDS = new Set([ + 'name', 'displayName', 'description', 'version', 'minClientVersions', 'author', 'publisher', 'homepage', + 'repository', 'license', 'logo', 'keywords', 'category', 'tags', 'commands', 'agents', 'skills', 'rules', + 'hooks', 'variables', 'mcpServers', +]); + +/** Cursor Plugin 名称的当前官方规则。 */ +const PLUGIN_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u; + +/** Cursor Hook 配置文件唯一允许的根字段。 */ +const HOOK_CONFIG_FIELDS = new Set(['version', 'hooks']); + +/** Cursor 当前验证过的 Plugin Hook 事件。 */ +const HOOK_EVENTS = new Set([ + 'sessionStart', 'sessionEnd', 'beforeSubmitPrompt', 'preToolUse', 'postToolUse', 'preCompact', + 'subagentStart', 'subagentStop', 'stop', +]); + +/** Cursor 远程 MCP descriptor 允许的完整字段。 */ +const MCP_SERVER_FIELDS = new Set(['url', 'headers']); + +/** MCP Server key 继续使用框架统一的稳定 ID。 */ +const MCP_SERVER_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + +/** JSON 对象的运行时只读索引类型。 */ +type JsonRecord = Record; + +/** + * 判断未知值是否为非数组 JSON 对象。 + * + * @param value 从候选清单解析的未知值。 + * @returns 可以按字段读取时返回 true。 + */ +function isRecord(value: unknown): value is JsonRecord { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * 向 Core 提交 Cursor 候选校验错误。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param code 稳定诊断码。 + * @param message 不包含宿主绝对路径的错误信息。 + * @param fieldPath 可选的清单字段位置。 + */ +function report( + context: PlatformValidateContext, + code: string, + message: string, + fieldPath?: readonly (string | number)[], +): void { + context.diagnostics.report({ + code, + severity: 'error', + message, + ...(fieldPath === undefined ? {} : { fieldPath }), + }); +} + +/** + * 判断 Manifest 路径是否留在 Plugin 根目录内。 + * + * @param reference Cursor Manifest 中的相对路径或 Glob。 + * @returns 使用 `./` 且没有目录逃逸时返回 true。 + */ +function isSafeReference(reference: string): boolean { + if (!reference.startsWith('./') || reference.includes('\\') || reference.includes('\0')) + return false; + /** 去掉 `./` 和 Glob 后用于路径规范化的静态前缀。 */ + const prefix = reference.slice(2).split(/[*?[\]{}]/u, 1)[0] ?? ''; + /** 规范化后的静态前缀。 */ + const normalized = path.posix.normalize(prefix); + return prefix.length > 0 && normalized !== '..' && !normalized.startsWith('../') && !path.posix.isAbsolute(normalized); +} + +/** + * 判断路径或 Glob 引用是否至少匹配一个已物化 Asset。 + * + * @param assets 当前 Package 的 Asset 路径集合。 + * @param reference 已通过安全检查的引用。 + * @returns 精确文件或 Glob 静态目录存在时返回 true。 + */ +function referenceExists(assets: ReadonlySet, reference: string): boolean { + /** 移除协议前缀并取得第一个 Glob 之前的稳定前缀。 */ + const relative = reference.slice(2); + /** 精确文件引用可直接判断。 */ + if (!/[*?[\]{}]/u.test(relative)) + return assets.has(relative) || [...assets].some(asset => asset.startsWith(`${relative.replace(/\/+$/u, '')}/`)); + /** Glob 引用只允许匹配第一个模式字符之前的静态目录前缀。 */ + const patternIndex = relative.search(/[*?[\]{}]/u); + /** 保留到最后一个完整目录边界,避免把文件名前缀误当目录。 */ + const staticPrefix = relative.slice(0, patternIndex); + /** 实际参与 Asset 前缀匹配的完整静态目录。 */ + const directory = staticPrefix.slice(0, staticPrefix.lastIndexOf('/') + 1); + return directory.length > 0 && [...assets].some(asset => asset.startsWith(directory)); +} + +/** Cursor logo 中显式 URL scheme 的稳定识别规则。 */ +const URL_SCHEME_PATTERN = /^[A-Za-z][A-Za-z\d+.-]*:/u; + +/** + * 校验 Cursor logo 的远端 URL 分支。 + * + * @param value 带显式 scheme 的 logo 候选。 + * @returns 仅无凭据 HTTPS 网络 URL 返回 true。 + */ +function isSafeLogoUrl(value: string): boolean { + try { + /** URL 解析后的协议、主机和凭据共同定义远端资源信任边界。 */ + const url = new URL(value); + return url.protocol === 'https:' + && url.hostname.length > 0 + && url.username === '' + && url.password === ''; + } catch { + return false; + } +} + +/** + * 校验 Cursor logo 的 Plugin 根相对路径分支。 + * + * @param value 不带 URL scheme 的 logo 候选。 + * @returns 安全路径对应的 Asset lookup key;非法时返回 undefined。 + */ +function logoAssetPath(value: string): string | undefined { + if (value === '' + || value.includes('\0') + || value.includes('\\') + || path.posix.isAbsolute(value) + || path.win32.isAbsolute(value) + || value.split('/').includes('..')) { + return undefined; + } + /** Cursor 接受可选 `./`,Package Asset 路径使用无前缀 POSIX 形式。 */ + const normalized = path.posix.normalize(value).replace(/^\.\//u, ''); + return normalized === '.' || normalized.startsWith('../') ? undefined : normalized; +} + +/** + * 按互斥 URL/Asset 分支校验 Cursor logo。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param assets 当前候选 Package 的 Asset 路径集合。 + * @param value Manifest logo 字段候选。 + */ +function validateLogo( + context: PlatformValidateContext, + assets: ReadonlySet, + value: JsonValue, +): void { + if (typeof value !== 'string') { + report(context, 'CURSOR_LOGO_PATH_INVALID', 'logo must be an HTTPS URL or a safe Plugin-root Asset path.', ['logo']); + return; + } + /** 本机绝对路径优先归入相对路径边界,避免盘符被误判成 URL scheme。 */ + if (path.posix.isAbsolute(value) || path.win32.isAbsolute(value) || value.includes('\\') || value.includes('\0')) { + report(context, 'CURSOR_LOGO_PATH_INVALID', 'logo path must be a safe POSIX path relative to the Plugin root.', ['logo']); + return; + } + if (URL_SCHEME_PATTERN.test(value)) { + if (!isSafeLogoUrl(value)) + report(context, 'CURSOR_LOGO_URL_INVALID', 'logo URL must be an absolute HTTPS URL without credentials.', ['logo']); + return; + } + /** 不带 scheme 的输入只能引用当前候选中实际存在的 Asset。 */ + const assetPath = logoAssetPath(value); + if (assetPath === undefined) + report(context, 'CURSOR_LOGO_PATH_INVALID', 'logo path must be a safe POSIX path relative to the Plugin root.', ['logo']); + else if (!assets.has(assetPath)) + report(context, 'CURSOR_LOGO_ASSET_MISSING', 'logo path must reference a generated Plugin Asset.', ['logo']); +} + +/** + * 校验 Cursor Manifest 路径字段的安全性和存在性。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param assets 当前 Asset 路径集合。 + * @param field Manifest 字段名。 + * @param value 待验证字段值。 + */ +function validateReference( + context: PlatformValidateContext, + assets: ReadonlySet, + field: string, + value: JsonValue, +): void { + if (typeof value !== 'string' || !isSafeReference(value)) { + report(context, 'CURSOR_MANIFEST_REFERENCE_INVALID', `${field} must be a safe Plugin-root path or glob.`, [field]); + } else if (!referenceExists(assets, value)) { + report(context, 'CURSOR_MANIFEST_REFERENCE_MISSING', `${field} references no generated Plugin Asset.`, [field]); + } +} + +/** 校验 Cursor version 1 Hook 配置的完整事件/命令结构。 */ +function validateHookConfig(context: PlatformValidateContext, value: JsonValue, fieldPath: readonly (string | number)[]): void { + if (!isRecord(value)) { + report(context, 'CURSOR_HOOK_CONFIG_INVALID', 'Cursor Hook config must be a JSON object.', fieldPath); + return; + } + for (const field of Object.keys(value)) { + if (!HOOK_CONFIG_FIELDS.has(field)) + report(context, 'CURSOR_HOOK_CONFIG_FIELD_UNKNOWN', `Unknown Cursor Hook config field "${field}".`, [...fieldPath, field]); + } + if (value.version !== 1) + report(context, 'CURSOR_HOOK_VERSION_INVALID', 'Cursor Hook config version must be 1.', [...fieldPath, 'version']); + if (!isRecord(value.hooks)) { + report(context, 'CURSOR_HOOK_EVENTS_INVALID', 'Cursor Hook config must contain an event mapping.', [...fieldPath, 'hooks']); + return; + } + for (const [event, handlers] of Object.entries(value.hooks)) { + /** 当前事件在最终 Hook 配置中的字段路径。 */ + const eventPath = [...fieldPath, 'hooks', event]; + if (!HOOK_EVENTS.has(event)) { + report(context, 'CURSOR_HOOK_EVENT_UNKNOWN', `Unknown Cursor Hook event "${event}".`, eventPath); + continue; + } + if (!Array.isArray(handlers) || handlers.length === 0) { + report(context, 'CURSOR_HOOK_HANDLERS_INVALID', 'Each Cursor Hook event must contain command handlers.', eventPath); + continue; + } + for (const [index, handler] of handlers.entries()) { + /** 单个 command Handler 的最终字段路径。 */ + const handlerPath = [...eventPath, index]; + if (!isRecord(handler)) { + report(context, 'CURSOR_HOOK_HANDLER_INVALID', 'Cursor Hook handlers must be objects.', handlerPath); + continue; + } + for (const field of Object.keys(handler)) { + if (field !== 'command') + report(context, 'CURSOR_HOOK_HANDLER_FIELD_UNKNOWN', `Unknown Cursor Hook handler field "${field}".`, [...handlerPath, field]); + } + if (typeof handler.command !== 'string' || handler.command.trim().length === 0) + report(context, 'CURSOR_HOOK_COMMAND_INVALID', 'Cursor Hook command must be a non-empty string.', [...handlerPath, 'command']); + } + } +} + +/** 校验 Cursor remote-only MCP Server 映射。 */ +function validateMcpServers(context: PlatformValidateContext, value: JsonValue, fieldPath: readonly (string | number)[]): void { + if (!isRecord(value)) { + report(context, 'CURSOR_MCP_SERVERS_INVALID', 'Cursor mcpServers must contain a Server object mapping.', fieldPath); + return; + } + for (const [id, candidate] of Object.entries(value)) { + /** 当前 Server 在最终配置中的字段路径。 */ + const serverPath = [...fieldPath, id]; + if (!MCP_SERVER_ID_PATTERN.test(id) || !isRecord(candidate)) { + report(context, 'CURSOR_MCP_SERVER_INVALID', 'MCP Server ids must use lowercase kebab-case and map to objects.', serverPath); + continue; + } + for (const field of Object.keys(candidate)) { + if (!MCP_SERVER_FIELDS.has(field)) + report(context, 'CURSOR_MCP_FIELD_UNKNOWN', `Unknown Cursor MCP field "${field}".`, [...serverPath, field]); + } + if (typeof candidate.url !== 'string') { + report(context, 'CURSOR_MCP_URL_INVALID', 'Cursor MCP url must be an HTTP(S) URL without credentials.', [...serverPath, 'url']); + } else { + try { + /** Cursor remote MCP 不接受 URL 内联凭据。 */ + const url = new URL(candidate.url); + if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username !== '' || url.password !== '') + throw new TypeError('unsafe'); + } catch { + report(context, 'CURSOR_MCP_URL_INVALID', 'Cursor MCP url must be an HTTP(S) URL without credentials.', [...serverPath, 'url']); + } + } + if (candidate.headers !== undefined + && (!isRecord(candidate.headers) + || Object.entries(candidate.headers).some(([key, header]) => key.trim().length === 0 || typeof header !== 'string'))) { + report(context, 'CURSOR_MCP_HEADERS_INVALID', 'Cursor MCP headers must map non-empty names to string values.', [...serverPath, 'headers']); + } + } +} + +/** 读取并校验 Cursor Extension 字段引用的最终 JSON 配置。 */ +async function validateExtensionFile( + context: PlatformValidateContext, + reference: string, + field: 'hooks' | 'mcpServers', +): Promise { + try { + /** Extension 配置引用相对于 Plugin candidate 根解析。 */ + const value: unknown = JSON.parse(await fs.readFile(path.join(context.candidate.root, reference.slice(2)), 'utf8')); + if (field === 'hooks') { + validateHookConfig(context, value as JsonValue, [field]); + return; + } + if (!isRecord(value) || Object.keys(value).some(key => key !== 'mcpServers') || value.mcpServers === undefined) { + report(context, 'CURSOR_MCP_CONFIG_INVALID', 'Cursor MCP config must contain only mcpServers.', [field]); + return; + } + validateMcpServers(context, value.mcpServers, [field, 'mcpServers']); + } catch { + report(context, 'CURSOR_EXTENSION_CONFIG_READ_FAILED', `${field} reference must contain valid JSON.`, [field]); + } +} + +/** + * 校验 Cursor 主 Plugin Manifest 与所有资源引用。 + * + * @param context Platform 提供的已物化候选交付单元。 + */ +export async function validateCursorPackage(context: PlatformValidateContext): Promise { + /** 当前候选 Package 的规范 Asset 路径集合。 */ + const assets = new Set(context.candidate.unit.assets.map(asset => asset.path)); + /** 从候选根加载且仍需 Schema 校验的 Manifest。 */ + let manifest: JsonRecord; + try { + /** JSON.parse 返回的未知值必须继续验证对象形态。 */ + const value: unknown = JSON.parse(await fs.readFile(path.join(context.candidate.root, PLUGIN_MANIFEST_PATH), 'utf8')); + if (!isRecord(value)) + throw new TypeError('Manifest is not an object.'); + manifest = value; + } catch { + report(context, 'CURSOR_MANIFEST_READ_FAILED', `${PLUGIN_MANIFEST_PATH} must contain a JSON object.`); + return; + } + /** field 表示当前 Manifest 根字段,用于实施官方 additionalProperties: false。 */ + for (const field of Object.keys(manifest)) { + if (!MANIFEST_FIELDS.has(field)) + report(context, 'CURSOR_MANIFEST_FIELD_UNKNOWN', `Unknown Cursor Plugin field "${field}".`, [field]); + } + if (typeof manifest.name !== 'string' || !PLUGIN_NAME_PATTERN.test(manifest.name)) + report(context, 'CURSOR_MANIFEST_NAME_INVALID', 'name must satisfy the official Cursor Plugin name pattern.', ['name']); + if (manifest.version !== undefined && (typeof manifest.version !== 'string' || !SEMVER_PATTERN.test(manifest.version))) + report(context, 'CURSOR_MANIFEST_VERSION_INVALID', 'version must be a semantic version.', ['version']); + /** field 表示当前由 acplugin 始终写入的非空字符串元数据。 */ + for (const field of ['description', 'version'] as const) { + if (typeof manifest[field] !== 'string' || manifest[field].trim().length === 0) + report(context, 'CURSOR_MANIFEST_METADATA_INVALID', `${field} must be a non-empty string.`, [field]); + } + if (manifest.author !== undefined) { + /** author 只允许 name 和 email,明确排除统一元数据的 url。 */ + const author = isRecord(manifest.author) ? manifest.author : undefined; + if (author === undefined || typeof author.name !== 'string' || author.name.trim().length === 0) + report(context, 'CURSOR_MANIFEST_AUTHOR_INVALID', 'author.name must be a non-empty string.', ['author']); + else if (Object.keys(author).some(field => field !== 'name' && field !== 'email')) + report(context, 'CURSOR_MANIFEST_AUTHOR_FIELD_UNKNOWN', 'author accepts only name and email.', ['author']); + } + if (manifest.logo !== undefined) + validateLogo(context, assets, manifest.logo); + /** field 表示当前 acplugin 可能生成的 Component Glob。 */ + for (const field of ['commands', 'skills', 'agents'] as const) { + if (manifest[field] !== undefined) + validateReference(context, assets, field, manifest[field]); + } + /** field 表示当前 Extension 贡献的固定配置文件引用。 */ + for (const field of ['hooks', 'mcpServers'] as const) { + if (typeof manifest[field] === 'string') { + validateReference(context, assets, field, manifest[field]); + if (isSafeReference(manifest[field]) && referenceExists(assets, manifest[field])) + await validateExtensionFile(context, manifest[field], field); + } else if (manifest[field] !== undefined && !isRecord(manifest[field])) { + report(context, 'CURSOR_EXTENSION_REFERENCE_INVALID', `${field} must be a path or inline object.`, [field]); + } else if (field === 'hooks' && manifest[field] !== undefined) { + validateHookConfig(context, manifest[field], [field]); + } else if (manifest[field] !== undefined) { + validateMcpServers(context, manifest[field], [field]); + } + } +} diff --git a/packages/platforms/cursor/src/types.ts b/packages/platforms/cursor/src/types.ts new file mode 100644 index 0000000..43eae94 --- /dev/null +++ b/packages/platforms/cursor/src/types.ts @@ -0,0 +1,73 @@ +/** Cursor Plugin Manifest 中经过官方 Schema 验证的平台专属选项。 */ +export interface CursorPlatformOptions { + /** 覆盖当前 Platform 的兼容性严格度。 */ + readonly strict?: boolean; + /** Marketplace/安装界面显示的发布组织。 */ + readonly publisher?: string; + /** 相对 Plugin 根或绝对 URL 的 Logo。 */ + readonly logo?: string; + /** Cursor Marketplace 分类。 */ + readonly category?: string; + /** Cursor Marketplace 标签。 */ + readonly tags?: readonly string[]; + /** 按客户端 ID 声明的最低语义版本。 */ + readonly minClientVersions?: Readonly>; +} + +/** Cursor Platform 接受的私有 Component union。 */ +export type CursorPackageComponent = CursorNativeAgentComponent; + +/** + * Cursor 原生 Subagent contribution。 + * + * 这是 Cursor 自己的窄表示,不映射其他 Platform 的模型、工具或权限字段。 + */ +export type CursorNativeAgentComponent = import('@tokenroll/acplugin/sdk').JsonObject & Readonly<{ + readonly kind: 'native-agent'; + readonly id: string; + readonly description: string; + readonly body: string; + readonly readonly?: boolean; +}>; + +/** Cursor 官方 Plugin Manifest 的受控结构。 */ +export interface CursorPluginManifest { + /** 稳定 Plugin ID。 */ + readonly name: string; + /** 人类可读展示名。 */ + readonly displayName?: string; + /** Plugin 说明。 */ + readonly description: string; + /** Plugin 语义版本。 */ + readonly version: string; + /** Cursor Schema 支持的作者姓名和邮件。 */ + readonly author?: { readonly name: string; readonly email?: string }; + /** 项目主页。 */ + readonly homepage?: string; + /** 源码仓库。 */ + readonly repository?: string; + /** SPDX License。 */ + readonly license?: string; + /** 搜索关键词。 */ + readonly keywords?: readonly string[]; + /** Platform 专属发布组织。 */ + readonly publisher?: string; + /** Platform 专属 Logo。 */ + readonly logo?: string; + /** Platform 专属分类。 */ + readonly category?: string; + /** Platform 专属标签。 */ + readonly tags?: readonly string[]; + /** 最低客户端版本映射。 */ + readonly minClientVersions?: Readonly>; + /** Command 文件 Glob。 */ + readonly commands?: string; + /** Skill 文件 Glob。 */ + readonly skills?: string; + /** Agent 文件 Glob。 */ + readonly agents?: string; + /** Hooks 配置路径或内联对象。 */ + readonly hooks?: string | Readonly>; + /** MCP 配置路径或内联对象。 */ + readonly mcpServers?: string | Readonly>; +} diff --git a/packages/platforms/cursor/test/golden/.cursor-plugin/plugin.json b/packages/platforms/cursor/test/golden/.cursor-plugin/plugin.json new file mode 100644 index 0000000..1ab350f --- /dev/null +++ b/packages/platforms/cursor/test/golden/.cursor-plugin/plugin.json @@ -0,0 +1,30 @@ +{ + "agents": "./agents/*.md", + "author": { + "email": "maintainers@example.com", + "name": "TokenRoll" + }, + "category": "Developer Tools", + "commands": "./commands/*.md", + "description": "Release workflow tools.", + "displayName": "Release Tools", + "homepage": "https://example.com/release-tools", + "keywords": [ + "release", + "review" + ], + "license": "MIT", + "logo": "./assets/logo.svg", + "minClientVersions": { + "cursor": "1.2.3" + }, + "name": "release-tools", + "publisher": "TokenRoll", + "repository": "https://github.com/TokenRollAI/release-tools", + "skills": "./skills/*/SKILL.md", + "tags": [ + "release", + "automation" + ], + "version": "1.2.3" +} diff --git a/packages/platforms/cursor/test/golden/plugin.schema.json b/packages/platforms/cursor/test/golden/plugin.schema.json new file mode 100644 index 0000000..51d4e3f --- /dev/null +++ b/packages/platforms/cursor/test/golden/plugin.schema.json @@ -0,0 +1,181 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://cursor.com/schemas/cursor-plugin/plugin.json", + "title": "Cursor Plugin Manifest", + "description": "Schema for .cursor-plugin/plugin.json — defines a single Cursor plugin's metadata, components, and configuration.", + "type": "object", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$", + "description": "Unique plugin identifier in kebab-case (lowercase alphanumeric with hyphens and periods)." + }, + "displayName": { + "type": "string", + "description": "Human-readable display name for the plugin." + }, + "description": { + "type": "string", + "description": "Short description of what the plugin does." + }, + "version": { + "type": "string", + "description": "Semantic version of the plugin (e.g. \"1.2.3\")." + }, + "minClientVersions": { + "$ref": "#/$defs/minClientVersions", + "description": "Minimum client versions required to install the plugin, keyed by client identifier." + }, + "author": { + "$ref": "#/$defs/author", + "description": "The plugin author." + }, + "publisher": { + "type": "string", + "minLength": 1, + "description": "Publisher or organisation name." + }, + "homepage": { + "type": "string", + "format": "uri", + "description": "URL to the plugin's homepage." + }, + "repository": { + "type": "string", + "format": "uri", + "description": "URL to the plugin's source code repository." + }, + "license": { + "type": "string", + "description": "SPDX license identifier (e.g. \"MIT\", \"Apache-2.0\")." + }, + "logo": { + "type": "string", + "description": "Path to a logo image (relative to the plugin root) or an absolute URL." + }, + "keywords": { + "type": "array", + "items": { "type": "string" }, + "description": "Keywords for discovery and search." + }, + "category": { + "type": "string", + "description": "Plugin category for marketplace classification." + }, + "tags": { + "type": "array", + "items": { "type": "string" }, + "description": "Tags for filtering and discovery." + }, + "commands": { + "$ref": "#/$defs/stringOrStringArray", + "description": "Glob pattern(s) or path(s) to command files." + }, + "agents": { + "$ref": "#/$defs/stringOrStringArray", + "description": "Glob pattern(s) or path(s) to agent definition files." + }, + "skills": { + "$ref": "#/$defs/stringOrStringArray", + "description": "Glob pattern(s) or path(s) to skill files." + }, + "rules": { + "$ref": "#/$defs/stringOrStringArray", + "description": "Glob pattern(s) or path(s) to rule files." + }, + "hooks": { + "oneOf": [ + { "type": "string" }, + { "type": "object" } + ], + "description": "Path to a hooks configuration file, or an inline hooks object." + }, + "variables": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "const": "object" + }, + "properties": { + "type": "object" + }, + "required": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true + } + }, + "description": "JSON Schema for user-configured plugin variables." + }, + "mcpServers": { + "$ref": "#/$defs/mcpServers", + "description": "MCP server configuration — a path, an inline config object, or an array of either." + } + }, + "$defs": { + "author": { + "type": "object", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Author name." + }, + "email": { + "type": "string", + "format": "email", + "description": "Author email address." + } + } + }, + "minClientVersions": { + "type": "object", + "minProperties": 1, + "properties": { + "cursor": { + "$ref": "#/$defs/semver", + "description": "Minimum Cursor version required to install the plugin (e.g. \"3.13.0\")." + } + }, + "additionalProperties": { + "$ref": "#/$defs/semver", + "description": "Minimum version required for another client identifier." + } + }, + "semver": { + "type": "string", + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?$", + "description": "Strict semantic version \"X.Y.Z\" with an optional prerelease suffix." + }, + "stringOrStringArray": { + "oneOf": [ + { "type": "string" }, + { + "type": "array", + "items": { "type": "string" } + } + ] + }, + "mcpServers": { + "oneOf": [ + { "type": "string" }, + { "type": "object" }, + { + "type": "array", + "items": { + "oneOf": [ + { "type": "string" }, + { "type": "object" } + ] + } + } + ] + } + } +} diff --git a/packages/platforms/cursor/test/platform.test.ts b/packages/platforms/cursor/test/platform.test.ts new file mode 100644 index 0000000..74c5a89 --- /dev/null +++ b/packages/platforms/cursor/test/platform.test.ts @@ -0,0 +1,455 @@ +import { createHash } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + defineExtension, + type AcpluginExtension, + type JsonValue, + type PlatformContributor, +} from '@acplugin/core'; +import { + resolveKernelConfig, + runKernelBuildSession, +} from '@acplugin/core'; +import { cursor, type CursorPackageComponent } from '../src/index.js'; +import { PLUGIN_MANIFEST_PATH } from '../src/package/manifest.js'; + +/** 测试结束后统一删除的临时工程根目录。 */ +const temporaryRoots: string[] = []; + +/** Cursor 官方 Schema 与 Manifest Golden 的固定目录。 */ +const goldenRoot = path.join(import.meta.dirname, 'golden'); + +/** 2026-08-13 从 Cursor 官方仓库重新核验的 Schema 内容摘要。 */ +const CURSOR_SCHEMA_SHA256 = 'a393b758901803fcf5cfe0d77bda8a83e987d32c3377dfce2d9edf445af884ed'; + +/** Cursor 官方 Schema 的固定上游来源。 */ +const CURSOR_SCHEMA_SOURCE = 'https://github.com/cursor/plugins/blob/main/schemas/plugin.schema.json'; + +/** 测试只读取的 Cursor Schema 最小结构。 */ +interface CursorSchemaFixture { + /** 官方 Schema 是否禁止未知根字段。 */ + readonly additionalProperties: boolean; + /** 官方 Manifest 必填字段。 */ + readonly required: readonly string[]; + /** 官方 Manifest 根字段定义。 */ + readonly properties: Readonly>; +} + +/** 创建已登记自动清理的规范工程。 */ +async function createProject(): Promise { + /** 当前用例独占的工程根目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-cursor-platform-')); + temporaryRoots.push(root); + await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/skills/review/references'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/agents'), { recursive: true }); + await fs.mkdir(path.join(root, 'public/assets'), { recursive: true }); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), 'export default {}\n'); + await fs.writeFile(path.join(root, 'src/commands/release.md'), '---\ndescription: Prepare a release.\n---\nPrepare release {{arguments}}.\n'); + await fs.writeFile(path.join(root, 'src/skills/review/SKILL.md'), '---\ndescription: Review a change.\n---\nReview the change.\n'); + await fs.writeFile(path.join(root, 'src/skills/review/references/checklist.md'), 'Review checklist.\n'); + await fs.writeFile(path.join(root, 'src/agents/reviewer.md'), '---\ndescription: Review code.\nmodel: inherit\ncapabilities:\n - filesystem:read\n - search\n---\nReview code.\n'); + await fs.writeFile(path.join(root, 'public/assets/logo.svg'), '\n'); + return root; +} + +/** 执行只包含 Cursor 的真实 Kernel v2 BuildSession。 */ +async function run(input: { + readonly root: string; + readonly platform?: ReturnType; + readonly extensions?: readonly AcpluginExtension[]; + readonly command?: 'validate' | 'inspect' | 'build'; + readonly commit?: boolean; +}) { + /** command 决定生命周期语义,commit 只允许 build 使用。 */ + const command = input.command ?? 'build'; + /** config 覆盖 Cursor 官方 Schema 支持的统一和平台专属字段。 */ + const resolved = resolveKernelConfig({ + name: 'release-tools', + version: '1.2.3', + description: 'Release workflow tools.', + displayName: 'Release Tools', + author: { name: 'TokenRoll', email: 'maintainers@example.com' }, + homepage: 'https://example.com/release-tools', + repository: 'https://github.com/TokenRollAI/release-tools', + license: 'MIT', + keywords: ['release', 'review'], + platforms: [input.platform ?? cursor({ + publisher: 'TokenRoll', + logo: './assets/logo.svg', + category: 'Developer Tools', + tags: ['release', 'automation'], + minClientVersions: { cursor: '1.2.3' }, + })], + extensions: input.extensions ?? [], + }, { + projectRoot: input.root, + configFile: path.join(input.root, 'acplugin.config.ts'), + command, + mode: 'production', + }); + expect(resolved.diagnostics).toEqual([]); + return (await runKernelBuildSession({ + config: resolved.config!, + frameworkVersion: 'test', + commit: command === 'build' && (input.commit ?? true), + })).report; +} + +/** 用冻结 Schema 的根字段约束校验生成 Manifest。 */ +function expectSchemaCompatible(manifest: Record, schema: CursorSchemaFixture): void { + expect(schema.additionalProperties).toBe(false); + expect(Object.keys(manifest).every(field => Object.hasOwn(schema.properties, field))).toBe(true); + expect(schema.required.every(field => Object.hasOwn(manifest, field))).toBe(true); + /** namePattern 来自固定官方 Schema,而不是复制生产实现的规则。 */ + const namePattern = schema.properties.name?.pattern; + expect(namePattern).toBeTypeOf('string'); + expect(String(manifest.name)).toMatch(new RegExp(namePattern!)); +} + +/** 创建向 Cursor 声明点 add-only 贡献配置和 Asset 的测试 Extension。 */ +function contribution(input: { + readonly id: string; + readonly field: 'hooks' | 'mcpServers'; + readonly value: JsonValue; + readonly path: string; + readonly bytes: string; +}): AcpluginExtension { + return defineExtension({ + id: input.id, + apiVersion: '1', + resourceRoots: [], + /** 每轮创建无共享可变状态的测试 Session。 */ + createSession: () => ({ + /** 非 undefined 空对象表示当前 Fixture 已发现。 */ + discover: () => ({}), + /** capability 声明要求 Contributor 完整覆盖。 */ + validate: (_context, state) => ({ + state, + subjects: [{ subject: `fixture:${input.id}`, capabilities: ['delivery'] }], + }), + /** bytes 只通过 Extension owner-scoped Asset Service 签发。 */ + build: async ({ assets }, state) => ({ + state: { + state, + asset: await assets.fromBytes({ + bytes: input.bytes, + origin: { operation: 'cursor-fixture', subjects: [`fixture:${input.id}`] }, + }), + }, + }), + contributors: [{ + platform: 'cursor', + platformApiVersion: '1', + /** Contributor 只填写声明点、追加自己的 Asset 并报告自己的 tuple。 */ + contribute: (_context, built) => ({ + documentFields: [{ document: 'plugin-manifest', path: [input.field], value: input.value }], + assets: [{ path: input.path, asset: built.asset }], + compatibility: [{ + subject: `fixture:${input.id}`, + capability: 'delivery', + level: 'native', + reason: 'The fixture is delivered through the Cursor Package contribution contract.', + }], + }), + }], + }), + }); +} + +/** 创建只由 Cursor Platform Component transport 交付 Native Agent 的中立 Extension。 */ +function nativeAgentContribution(input: { + readonly id: string; + readonly agents: readonly Record[]; +}): AcpluginExtension { + /** payload 类型只存在于 Cursor Platform 与其 Contributor 的边界。 */ + const contributor: PlatformContributor, CursorPackageComponent> = { + platform: 'cursor', + platformApiVersion: '1', + contribute: () => ({ + components: input.agents.map(agent => ({ + subject: `fixture:${input.id}`, + value: agent as CursorPackageComponent, + })), + compatibility: [{ + subject: `fixture:${input.id}`, + capability: 'delivery', + level: 'native', + reason: 'The fixture is delivered as a native Cursor Subagent.', + }], + }), + }; + return defineExtension({ + id: input.id, + apiVersion: '1', + resourceRoots: [], + createSession: () => ({ + discover: () => ({}), + validate: (_context, discovered) => ({ + state: discovered, + subjects: [{ subject: `fixture:${input.id}`, capabilities: ['delivery'] }], + }), + build: (_context, validated) => ({ state: validated }), + contributors: [contributor], + }), + }); +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('Cursor Platform Package API', () => { + it('builds native Components, Public, metadata and the pinned official manifest golden', async () => { + /** root 包含三类原生 Component 和清单引用的 Public logo。 */ + const root = await createProject(); + /** report 来自真实 Package lifecycle、候选校验和事务。 */ + const report = await run({ root }); + /** Cursor 官方 Schema 的冻结原始字节。 */ + const schemaBytes = await fs.readFile(path.join(goldenRoot, 'plugin.schema.json')); + /** 从冻结 Fixture 解析出的官方 Schema。 */ + const schema = JSON.parse(schemaBytes.toString('utf8')) as CursorSchemaFixture; + /** output 是 Cursor 主 Plugin 根。 */ + const output = path.join(root, 'dist/cursor/plugin'); + /** manifest 是 Core JSON codec 生成并通过最终 validator 的对象。 */ + const manifest = JSON.parse(await fs.readFile(path.join(output, PLUGIN_MANIFEST_PATH), 'utf8')) as Record; + + expect(report.success, JSON.stringify(report.diagnostics, null, 2)).toBe(true); + expect(report.committed).toBe(true); + expect(report.compatibility).toEqual(expect.arrayContaining([ + expect.objectContaining({ subject: 'command:release', capability: 'component', level: 'native' }), + expect.objectContaining({ subject: 'skill:review', capability: 'component', level: 'native' }), + expect.objectContaining({ subject: 'agent:reviewer', capability: 'component', level: 'native' }), + ])); + expect(createHash('sha256').update(schemaBytes).digest('hex')).toBe(CURSOR_SCHEMA_SHA256); + expect(CURSOR_SCHEMA_SOURCE).toContain('cursor/plugins'); + expectSchemaCompatible(manifest, schema); + await expect(fs.readFile(path.join(output, PLUGIN_MANIFEST_PATH))).resolves.toEqual( + await fs.readFile(path.join(goldenRoot, PLUGIN_MANIFEST_PATH)), + ); + await expect(fs.readFile(path.join(output, 'commands/release.md'), 'utf8')).resolves.toContain('$ARGUMENTS'); + await expect(fs.readFile(path.join(output, 'skills/review/references/checklist.md'), 'utf8')).resolves.toBe('Review checklist.\n'); + await expect(fs.readFile(path.join(output, 'agents/reviewer.md'), 'utf8')).resolves.toContain('readonly: true'); + }); + + it('reports unsupported Runtime without generating fake assets', async () => { + /** Cursor 没有稳定 Plugin-local Node 契约,Runtime 只能显式 unsupported。 */ + const root = await createProject(); + await fs.mkdir(path.join(root, 'src/runtime'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/runtime/cli.ts'), 'import "missing-runtime-package";\n'); + /** relaxed 只接受已报告的 capability 差异,不改变结构校验。 */ + const report = await run({ root, platform: cursor({ strict: false }) }); + + expect(report.success, JSON.stringify(report.diagnostics, null, 2)).toBe(true); + expect(report.runtimes).toEqual([{ + id: 'cli', kind: 'executable', location: { path: 'src/runtime/cli.ts' }, built: false, + }]); + expect(report.compatibility).toContainEqual(expect.objectContaining({ + platform: 'cursor', subject: 'runtime:cli', capability: 'node20-esm', level: 'unsupported', + })); + expect(report.packages.flatMap(unit => unit.assets).some(asset => asset.path.startsWith('runtime/'))).toBe(false); + await expect(fs.access(path.join(root, 'dist/cursor/plugin/runtime'))).rejects.toThrow(); + }); + + it('renders Platform-owned Native Agent contributions and records only trusted contributor provenance', async () => { + /** 没有 canonical Agent 时,唯一 Agent 来自独立 Extension 的 private payload。 */ + const root = await createProject(); + await fs.rm(path.join(root, 'src/agents/reviewer.md')); + const report = await run({ + root, + extensions: [nativeAgentContribution({ + id: 'private-fixture', + agents: [{ kind: 'native-agent', id: 'observer', description: 'Observe the project.', body: 'Observe.', readonly: true }], + })], + }); + const output = path.join(root, 'dist/cursor/plugin'); + const manifest = JSON.parse(await fs.readFile(path.join(output, PLUGIN_MANIFEST_PATH), 'utf8')) as Record; + const asset = report.packages[0]!.assets.find(candidate => candidate.path === 'agents/observer.md'); + + expect(report.success, JSON.stringify(report.diagnostics, null, 2)).toBe(true); + expect(manifest.agents).toBe('./agents/*.md'); + await expect(fs.readFile(path.join(output, 'agents/observer.md'), 'utf8')).resolves.toContain('readonly: true'); + expect(asset).toMatchObject({ + owner: 'platform:cursor', + origin: { contributors: [{ owner: 'extension:private-fixture', subject: 'fixture:private-fixture' }] }, + }); + expect(report.packages[0]!.assets.find(candidate => candidate.path === PLUGIN_MANIFEST_PATH)).toMatchObject({ + origin: { contributors: [{ owner: 'extension:private-fixture', subject: 'fixture:private-fixture' }] }, + }); + }); + + it('rejects malformed and colliding Native Agent contributions independently of Extension order', async () => { + /** canonical 与 private Agent 共享 Cursor 的目标文件命名空间。 */ + const canonicalRoot = await createProject(); + const canonical = await run({ + root: canonicalRoot, + command: 'validate', + commit: false, + extensions: [nativeAgentContribution({ + id: 'canonical-collision', + agents: [{ kind: 'native-agent', id: 'reviewer', description: 'Duplicate.', body: 'Duplicate.' }], + })], + }); + expect(canonical.success).toBe(false); + expect(canonical.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CURSOR_COMPONENT_CONTRIBUTION_COLLISION', phase: 'finalize', platform: 'cursor', + })); + + /** 未知 wire field 由 Cursor,而非 Core 或 Extension,进行最终 schema 拒绝。 */ + const malformedRoot = await createProject(); + await fs.rm(path.join(malformedRoot, 'src/agents/reviewer.md')); + const malformed = await run({ + root: malformedRoot, + command: 'validate', + commit: false, + extensions: [nativeAgentContribution({ + id: 'malformed-agent', + agents: [{ kind: 'native-agent', id: 'invalid', description: 'Invalid.', body: 'Invalid.', unsupported: true }], + })], + }); + expect(malformed.success).toBe(false); + expect(malformed.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CURSOR_COMPONENT_CONTRIBUTION_INVALID', phase: 'finalize', platform: 'cursor', + })); + + /** 两个 private Agent 使用同一稳定 ID 时,异常摘要不能依赖配置顺序。 */ + const ordered = [ + nativeAgentContribution({ id: 'zeta-fixture', agents: [{ kind: 'native-agent', id: 'same', description: 'Same.', body: 'Same.' }] }), + nativeAgentContribution({ id: 'alpha-fixture', agents: [{ kind: 'native-agent', id: 'same', description: 'Same.', body: 'Same.' }] }), + ]; + const firstRoot = await createProject(); + await fs.rm(path.join(firstRoot, 'src/agents/reviewer.md')); + const first = await run({ root: firstRoot, command: 'validate', commit: false, extensions: ordered }); + const secondRoot = await createProject(); + await fs.rm(path.join(secondRoot, 'src/agents/reviewer.md')); + const second = await run({ root: secondRoot, command: 'validate', commit: false, extensions: [...ordered].reverse() }); + expect(first.success).toBe(false); + expect(second.success).toBe(false); + expect(first.diagnostics).toEqual(second.diagnostics); + expect(first.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CURSOR_COMPONENT_CONTRIBUTION_COLLISION', phase: 'finalize', platform: 'cursor', + })); + }); + + it('accepts Hooks/MCP add-only contributions and rejects duplicate point occupation', async () => { + /** validRoot 验证两个独立声明点及最终引用闭包。 */ + const validRoot = await createProject(); + /** hooks 引用一个 Cursor 配置文件。 */ + const hooks = contribution({ + id: 'hooks-fixture', field: 'hooks', value: './hooks/hooks.json', + path: 'hooks/hooks.json', bytes: '{"version":1,"hooks":{}}\n', + }); + /** mcpServers 引用一个独立配置文件。 */ + const mcp = contribution({ + id: 'mcp-fixture', field: 'mcpServers', value: './mcp.json', + path: 'mcp.json', bytes: '{"mcpServers":{}}\n', + }); + /** valid 必须在集中合并后通过最终候选 validator。 */ + const valid = await run({ root: validRoot, extensions: [hooks, mcp] }); + /** manifest 精确观察 Core 合并后的两个字段。 */ + const manifest = JSON.parse(await fs.readFile(path.join(validRoot, 'dist/cursor/plugin', PLUGIN_MANIFEST_PATH), 'utf8')); + expect(valid.success, JSON.stringify(valid.diagnostics, null, 2)).toBe(true); + expect(manifest).toMatchObject({ hooks: './hooks/hooks.json', mcpServers: './mcp.json' }); + + /** collisionRoot 隔离两个 Extension 同时占用 hooks 声明点。 */ + const collisionRoot = await createProject(); + /** secondHooks 使用不同 Asset 但占用完全相同的 Document path。 */ + const secondHooks = contribution({ + id: 'second-hooks', field: 'hooks', value: './hooks/second.json', + path: 'hooks/second.json', bytes: '{"version":1,"hooks":{}}\n', + }); + /** collision 必须由 Core merge 拒绝而不是依赖 Extension 执行顺序。 */ + const collision = await run({ + root: collisionRoot, + command: 'validate', + extensions: [hooks, secondHooks], + commit: false, + }); + expect(collision.success).toBe(false); + expect(collision.diagnostics).toContainEqual(expect.objectContaining({ + code: 'PLATFORM_CONTRIBUTION_FAILED', platform: 'cursor', phase: 'contribute', + })); + }); + + it('rejects malformed merged extension data at the final candidate boundary', async () => { + /** root 包含合法 base Package,错误只来自贡献后的最终 wire data。 */ + const root = await createProject(); + /** malformed 将 hooks 填成官方 Schema 不接受的布尔值。 */ + const malformed = contribution({ + id: 'malformed-hooks', field: 'hooks', value: false, + path: 'hooks/unused.json', bytes: '{}\n', + }); + /** report 应保留 Cursor validator 的稳定诊断。 */ + const report = await run({ root, command: 'validate', extensions: [malformed], commit: false }); + + expect(report.success).toBe(false); + expect(report.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CURSOR_EXTENSION_REFERENCE_INVALID', platform: 'cursor', phase: 'platform-validate', + })); + + /** mcpRoot 验证合法 sidecar 容器中的嵌套 Server 字段。 */ + const mcpRoot = await createProject(); + /** invalidMcp 的 headers 不是 Cursor 协议要求的字符串映射。 */ + const invalidMcp = contribution({ + id: 'invalid-mcp', field: 'mcpServers', value: './mcp.json', path: 'mcp.json', + bytes: '{"mcpServers":{"docs":{"url":"https://example.com/mcp","headers":42}}}\n', + }); + /** mcpReport 必须由最终 Candidate validator 而不是 Contributor 自校验拒绝。 */ + const mcpReport = await run({ root: mcpRoot, command: 'validate', extensions: [invalidMcp], commit: false }); + expect(mcpReport.success).toBe(false); + expect(mcpReport.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CURSOR_MCP_HEADERS_INVALID', platform: 'cursor', phase: 'platform-validate', + })); + }); + + it('validates factory options, Component fields and HTTPS/local logo boundaries', async () => { + expect(() => cursor({ experimental: true } as never)).toThrow('Unknown Cursor Platform option'); + expect(() => cursor({ tags: ['duplicate', 'duplicate'] })).toThrow('unique'); + expect(() => cursor({ minClientVersions: { cursor: 'latest' } })).toThrow('semantic versions'); + + /** componentRoot 的 raw Cursor namespace 必须在 Asset 创建前失败。 */ + const componentRoot = await createProject(); + await fs.writeFile(path.join(componentRoot, 'src/commands/release.md'), `--- +description: Invalid platform field. +platforms: + cursor: + raw: true +--- +Do not build. +`); + /** componentReport 保留 canonical field path。 */ + const componentReport = await run({ root: componentRoot, command: 'validate', commit: false }); + expect(componentReport.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CURSOR_COMPONENT_FIELD_UNKNOWN', fieldPath: ['platforms', 'cursor', 'raw'], + })); + + /** httpsRoot 的无凭据 HTTPS logo 不要求候选中存在同名 Asset。 */ + const httpsRoot = await createProject(); + /** httpsReport 验证 URL 与本地引用是互斥分支。 */ + const httpsReport = await run({ + root: httpsRoot, + platform: cursor({ logo: 'https://cdn.example.com/plugin/logo.svg' }), + }); + expect(httpsReport.success, JSON.stringify(httpsReport.diagnostics, null, 2)).toBe(true); + + /** unsafe logo 候选必须各自得到稳定诊断而不是读取宿主路径。 */ + const fixtures = [ + ['file:///tmp/logo.svg', 'CURSOR_LOGO_URL_INVALID'], + ['https://user:secret@example.com/logo.svg', 'CURSOR_LOGO_URL_INVALID'], + ['/tmp/logo.svg', 'CURSOR_LOGO_PATH_INVALID'], + ['C:\\temp\\logo.svg', 'CURSOR_LOGO_PATH_INVALID'], + ['../../logo.svg', 'CURSOR_LOGO_PATH_INVALID'], + ['./assets/missing.svg', 'CURSOR_LOGO_ASSET_MISSING'], + ] as const; + for (const [logo, code] of fixtures) { + /** 当前 logo 使用独立工程,避免失败事务互相影响。 */ + const root = await createProject(); + /** report 必须在最终候选边界拒绝不可信引用。 */ + const report = await run({ root, command: 'validate', platform: cursor({ logo }), commit: false }); + expect(report.diagnostics).toContainEqual(expect.objectContaining({ code, platform: 'cursor', fieldPath: ['logo'] })); + } + }); +}); diff --git a/packages/platforms/cursor/tsconfig.json b/packages/platforms/cursor/tsconfig.json new file mode 100644 index 0000000..3ae4da2 --- /dev/null +++ b/packages/platforms/cursor/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/platforms/cursor/tsdown.config.ts b/packages/platforms/cursor/tsdown.config.ts new file mode 100644 index 0000000..cc2b775 --- /dev/null +++ b/packages/platforms/cursor/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsdown'; + +/** Cursor Platform 使用统一 Node 20 ESM 与声明输出。 */ +export default defineConfig({ + entry: ['./src/index.ts'], + format: ['esm'], + platform: 'node', + target: 'node20', + dts: { generator: 'oxc' }, + clean: true, + sourcemap: false, + deps: { neverBundle: ['@tokenroll/acplugin'] }, +}); diff --git a/packages/platforms/cursor/vitest.config.ts b/packages/platforms/cursor/vitest.config.ts new file mode 100644 index 0000000..f795a78 --- /dev/null +++ b/packages/platforms/cursor/vitest.config.ts @@ -0,0 +1,22 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +/** Cursor 单测让公开主包与私有 Core 共享同一源码品牌实例。 */ +export default defineConfig({ + resolve: { + alias: [ + { + find: /^@tokenroll\/acplugin\/sdk$/, + replacement: fileURLToPath(new URL('../../acplugin/src/sdk.ts', import.meta.url)), + }, + { + find: /^@tokenroll\/acplugin$/, + replacement: fileURLToPath(new URL('../../acplugin/src/index.ts', import.meta.url)), + }, + { + find: /^@acplugin\/core$/, + replacement: fileURLToPath(new URL('../../core/src/index.ts', import.meta.url)), + }, + ], + }, +}); diff --git a/packages/platforms/opencode/CHANGELOG.md b/packages/platforms/opencode/CHANGELOG.md new file mode 100644 index 0000000..6452bdc --- /dev/null +++ b/packages/platforms/opencode/CHANGELOG.md @@ -0,0 +1,26 @@ +# @tokenroll/acplugin-platform-opencode + +## 0.0.3-beta + +### Major Changes + +- Add opaque, subject-bound Platform Component Contributions to the trusted Integration SDK. Core now transports strict JSON payloads and records scoped contributor provenance in BuildReport schema version 3 without acquiring Platform-specific Agent or target-format knowledge. + + Claude Code, Cursor, and OpenCode expose and render their own native Agent contribution payloads during Platform finalization. Codex, Antigravity, and Pi explicitly reject non-empty private component contributions rather than silently dropping them or generating fallback Skills. + + Harden `AssetService.fromBytes()` to accept only exact data-object inputs, exact generated-origin fields, and `string | Uint8Array` bytes so third-party Integrations cannot rely on accessor, hidden-field, or array-like coercion. + +### Patch Changes + +- Updated dependencies + - @tokenroll/acplugin@0.0.3-beta + +## 0.0.2-beta + +### Major Changes + +- 889da32: Rewrite the OpenCode Platform around a first-class workspace Package, Core-owned omit-if-empty Document codecs, native workspace Component Assets, an add-only MCP field, final candidate validation, and explicit unsupported Node Runtime compatibility without Plugin-root emulation. + +### Patch Changes + +- Updated peer dependency on `@tokenroll/acplugin` to `^0.0.2-beta`. diff --git a/packages/platforms/opencode/LICENSE b/packages/platforms/opencode/LICENSE new file mode 100644 index 0000000..9113de7 --- /dev/null +++ b/packages/platforms/opencode/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 TokenRollAI + +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/packages/platforms/opencode/README.md b/packages/platforms/opencode/README.md new file mode 100644 index 0000000..bfa62c6 --- /dev/null +++ b/packages/platforms/opencode/README.md @@ -0,0 +1,27 @@ +# @tokenroll/acplugin-platform-opencode + +OpenCode Platform package for `@tokenroll/acplugin`. + +```bash +pnpm add -D @tokenroll/acplugin @tokenroll/acplugin-platform-opencode +``` + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import openCode from '@tokenroll/acplugin-platform-opencode'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [openCode()], +}); +``` + +The package also exports the named `openCode` factory, its option types, `OpenCodePackageComponent`, `OpenCodeNativeAgentComponent`, `PLATFORM_ID`, and `PLATFORM_API_VERSION`. + +`OpenCodePackageComponent` is for trusted Extension contributors that need native workspace delivery. OpenCode owns its validation and rendering and does not create a synthetic Plugin Manifest for it. + +## License + +MIT diff --git a/packages/platforms/opencode/package.json b/packages/platforms/opencode/package.json new file mode 100644 index 0000000..b8d13da --- /dev/null +++ b/packages/platforms/opencode/package.json @@ -0,0 +1,29 @@ +{ + "name": "@tokenroll/acplugin-platform-opencode", + "version": "0.0.3-beta", + "description": "OpenCode Platform integration for acplugin.", + "type": "module", + "license": "MIT", + "homepage": "https://github.com/TokenRollAI/acplugin#opencode-platform", + "repository": { "type": "git", "url": "git+https://github.com/TokenRollAI/acplugin.git", "directory": "packages/platforms/opencode" }, + "bugs": { "url": "https://github.com/TokenRollAI/acplugin/issues" }, + "sideEffects": false, + "engines": { "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, + "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" } }, + "files": ["dist", "README.md", "LICENSE"], + "publishConfig": { "access": "public" }, + "scripts": { + "build": "tsdown", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc -p tsconfig.json" + }, + "peerDependencies": { "@tokenroll/acplugin": "workspace:^" }, + "devDependencies": { + "@acplugin/core": "workspace:*", + "@tokenroll/acplugin": "workspace:^", + "@types/node": "catalog:", + "tsdown": "catalog:", + "@typescript/native": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/platforms/opencode/src/index.ts b/packages/platforms/opencode/src/index.ts new file mode 100644 index 0000000..9e22c55 --- /dev/null +++ b/packages/platforms/opencode/src/index.ts @@ -0,0 +1,202 @@ +import { + definePlatform, + type AcpluginPlatform, + type ContributedPackageComponent, + type JsonObject, +} from '@tokenroll/acplugin/sdk'; +import { + createOpenCodeComponents, + openCodeNativeAgentDocument, + renderOpenCodeAgent, + validateOpenCodeComponent, +} from './package/components.js'; +import { createWorkspaceDocument, validatePlatformOptions } from './package/config-document.js'; +import type { OpenCodeNativeAgentComponent, OpenCodePackageComponent, OpenCodePlatformOptions } from './types.js'; +import { validateOpenCodePackage } from './package/validator.js'; + +export type { + OpenCodeNativeAgentComponent, + OpenCodePackageComponent, + OpenCodePlatformOptions, + OpenCodeWorkspaceOptions, +} from './types.js'; + +/** OpenCode Platform 的稳定开放 ID。 */ +export const PLATFORM_ID = 'opencode' as const; + +/** OpenCode Platform 实现的 Core API 版本。 */ +export const PLATFORM_API_VERSION = '1' as const; + +const STABLE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; +const TOOLS = new Set(['read', 'glob', 'grep', 'edit', 'bash', 'webfetch', 'task']); +const PERMISSIONS = new Set(['edit', 'bash', 'webfetch', 'task']); + +/** Platform-owned payload errors remain distinguishable from unexpected implementation failures. */ +class OpenCodeComponentContributionError extends Error { + constructor( + readonly category: 'invalid' | 'collision', + message: string, + ) { + super(message); + this.name = 'OpenCodeComponentContributionError'; + } +} + +/** @returns 是否为 Platform-safe 单行展示文本。 */ +function nonEmptyText(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0 && !/[\r\n\t\0]/u.test(value); +} + +/** 读取 OpenCode wire map,拒绝未声明字段或不匹配的标量。 */ +function record(value: unknown, allowed: ReadonlySet, label: string, valid: (value: unknown) => boolean): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value) + || (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) + || Object.getOwnPropertySymbols(value).length > 0) { + throw new TypeError(`${label} must be a plain object.`); + } + const fields = Object.getOwnPropertyDescriptors(value); + for (const [field, descriptor] of Object.entries(fields)) { + if (!allowed.has(field)) + throw new TypeError(`Unknown ${label} field "${field}".`); + if (!('value' in descriptor) || descriptor.enumerable !== true || !valid(descriptor.value)) + throw new TypeError(`${label}.${field} is invalid.`); + } + return Object.fromEntries(Object.entries(fields).map(([field, descriptor]) => [field, descriptor.value])); +} + +/** 解析 OpenCode 自己拥有的 Native Agent payload schema。 */ +function nativeAgent(value: unknown): OpenCodeNativeAgentComponent { + if (typeof value !== 'object' || value === null || Array.isArray(value) + || (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) + || Object.getOwnPropertySymbols(value).length > 0) { + throw new TypeError('OpenCode Platform Component must be a plain object.'); + } + const allowed = new Set(['kind', 'id', 'description', 'body', 'tools', 'permission']); + const fields = Object.getOwnPropertyDescriptors(value); + for (const [field, descriptor] of Object.entries(fields)) { + if (!allowed.has(field)) + throw new TypeError(`Unknown OpenCode Platform Component field "${field}".`); + if (!('value' in descriptor) || descriptor.enumerable !== true) + throw new TypeError(`OpenCode Platform Component.${field} must be an enumerable data property.`); + } + const input = value as Record; + if (input.kind !== 'native-agent') + throw new TypeError('OpenCode Platform Component kind must be native-agent.'); + if (typeof input.id !== 'string' || !STABLE_ID.test(input.id)) + throw new TypeError('OpenCode Platform Component id must use lowercase kebab-case.'); + if (!nonEmptyText(input.description)) + throw new TypeError('OpenCode Platform Component description must be a non-empty stable single-line string.'); + if (typeof input.body !== 'string' || input.body.trim().length === 0) + throw new TypeError('OpenCode Platform Component body must be non-empty.'); + const tools = input.tools === undefined ? undefined : record(input.tools, TOOLS, 'OpenCode Platform Component tools', item => typeof item === 'boolean'); + const permission = input.permission === undefined ? undefined : record(input.permission, PERMISSIONS, 'OpenCode Platform Component permission', item => item === 'allow' || item === 'deny'); + return Object.freeze({ + kind: 'native-agent' as const, + id: input.id, + description: input.description, + body: input.body, + ...(tools === undefined ? {} : { tools: tools as NonNullable }), + ...(permission === undefined ? {} : { permission: permission as NonNullable }), + }) as OpenCodeNativeAgentComponent; +} + +/** OpenCode Agent namespace follows target filesystem case/NFC collision semantics. */ +function agentCollisionKey(id: string): string { + return id.normalize('NFC').toLowerCase(); +} + +/** 将 OpenCode private payload 解析、校验并渲染为 workspace Agent Assets。 */ +async function contributedAgents( + components: readonly ContributedPackageComponent[], + canonicalIds: readonly string[], + assets: import('@tokenroll/acplugin/sdk').FinalizationAssetService, +): Promise { + const occupied = new Map(canonicalIds.map(id => [agentCollisionKey(id), `canonical Agent "${id}"`])); + let parsed: readonly { readonly component: OpenCodeNativeAgentComponent; readonly origin: import('@tokenroll/acplugin/sdk').PackageComponentOrigin }[]; + try { + parsed = components.map(component => Object.freeze({ component: nativeAgent(component.value), origin: component.origin })); + } catch (error) { + if (!(error instanceof TypeError)) + throw error; + throw new OpenCodeComponentContributionError('invalid', error.message); + } + for (const { component } of parsed) { + const key = agentCollisionKey(component.id); + const existing = occupied.get(key); + if (existing !== undefined) { + throw new OpenCodeComponentContributionError( + 'collision', + 'OpenCode Native Agent "' + component.id + '" collides with ' + existing + '.', + ); + } + occupied.set(key, `contributed Native Agent "${component.id}"`); + } + const output: import('@tokenroll/acplugin/sdk').PackageAssetInput[] = []; + for (const { component, origin } of [...parsed].sort((left, right) => left.component.id < right.component.id ? -1 : left.component.id > right.component.id ? 1 : 0)) { + const asset = await assets.fromBytes({ + bytes: renderOpenCodeAgent(openCodeNativeAgentDocument(component)), + origin: { operation: 'platform-component-agent', subjects: [origin.subject], componentOrigins: [origin] }, + }); + output.push(Object.freeze({ path: `.opencode/agents/${component.id}.md`, asset })); + } + return Object.freeze(output); +} + +/** 创建只通过 Package API 交付 OpenCode workspace overlay 的 Platform。 */ +export function openCode(options: OpenCodePlatformOptions = {}): AcpluginPlatform { + validatePlatformOptions(options); + /** strict 由 Core 解释,其余选项复制、深冻后进入 Platform Session。 */ + const { strict, ...platformOptions } = options; + return definePlatform({ + id: PLATFORM_ID, + apiVersion: PLATFORM_API_VERSION, + deliveryType: 'workspace', + ...(strict === undefined ? {} : { strict }), + options: platformOptions as unknown as JsonObject, + /** OpenCode workspace 不声明 Plugin-local Node Runtime 能力。 */ + createSession({ options: sessionOptions }) { + return { + validateComponent: validateOpenCodeComponent, + /** base Package 包含 workspace Components 和可省略的结构化配置。 */ + async createPackage({ project, assets }) { + /** components 全部通过 Platform owner 的 Asset Service 签发。 */ + const components = await createOpenCodeComponents(project, assets); + /** workspace config 由 Core codec 处理并只开放 MCP 字段。 */ + const config = createWorkspaceDocument({ metadata: project.metadata, options: sessionOptions }); + return { + documents: [config.document], + assets: components.assets, + compatibility: components.compatibility, + metadata: config.metadata, + }; + }, + /** 主单元身份明确是 workspace,不伪装 Plugin root;Native Agent 不要求 Config patch。 */ + async finalizePackage({ project, package: mergedPackage, assets, diagnostics }) { + let contributed: Awaited>; + try { + contributed = await contributedAgents(mergedPackage.components, project.agents.map(agent => agent.id), assets); + } catch (error) { + if (!(error instanceof OpenCodeComponentContributionError)) + throw error; + diagnostics.report({ + code: error.category === 'collision' + ? 'OPENCODE_COMPONENT_CONTRIBUTION_COLLISION' + : 'OPENCODE_COMPONENT_CONTRIBUTION_INVALID', + severity: 'error', + message: error.message, + }); + return { id: 'workspace', type: 'workspace' as const }; + } + return { + id: 'workspace', + type: 'workspace' as const, + assets: contributed, + }; + }, + validatePackage: validateOpenCodePackage, + }; + }, + }); +} + +export default openCode; diff --git a/packages/platforms/opencode/src/package/components.ts b/packages/platforms/opencode/src/package/components.ts new file mode 100644 index 0000000..0ce40ca --- /dev/null +++ b/packages/platforms/opencode/src/package/components.ts @@ -0,0 +1,197 @@ +import { + markdownWithFrontmatter, + type AgentCapability, + type AssetService, + type CanonicalProject, + type CompatibilityInput, + type PackageAssetInput, + type PlatformComponentValidationContext, +} from '@tokenroll/acplugin/sdk'; +import type { OpenCodeNativeAgentComponent } from '../types.js'; + +/** OpenCode 当前不开放未经独立 Schema 验证的 Component 专属字段。 */ +const COMPONENT_FIELDS = new Set(); + +/** OpenCode Agent 可以通过 tools/permission 控制的稳定工具名称。 */ +const OPENCODE_TOOLS = ['read', 'glob', 'grep', 'edit', 'bash', 'webfetch', 'task'] as const; + +/** OpenCode base Workspace 的 Component 转换结果。 */ +export interface OpenCodeComponentPackage { + readonly assets: readonly PackageAssetInput[]; + readonly compatibility: readonly CompatibilityInput[]; +} + +/** 校验 OpenCode Component namespace,不允许任意 Frontmatter 透传。 */ +export function validateOpenCodeComponent(context: PlatformComponentValidationContext): void { + /** fields 是 Scanner 已复制冻结的平台 namespace。 */ + const fields = context.component.platforms.opencode ?? {}; + for (const field of Object.keys(fields)) { + if (!COMPONENT_FIELDS.has(field)) { + context.diagnostics.report({ + code: 'OPENCODE_COMPONENT_FIELD_UNKNOWN', + severity: 'error', + message: `Unknown OpenCode ${context.component.kind} field "${field}".`, + fieldPath: ['platforms', 'opencode', field], + }); + } + } +} + +/** @returns canonical Agent capabilities 对应的完整 OpenCode 工具开关。 */ +function openCodeTools(capabilities: readonly AgentCapability[]): Readonly> { + /** allowed 累积多个 capability 映射到的去重工具。 */ + const allowed = new Set(); + /** mapping 是 canonical capability 到 OpenCode 工具的稳定映射。 */ + const mapping = { + 'filesystem:read': ['read', 'glob', 'grep'], + 'filesystem:write': ['edit'], + 'search': ['glob', 'grep'], + 'shell': ['bash'], + 'network': ['webfetch'], + 'delegate': ['task'], + } satisfies Record; + for (const capability of capabilities) { + for (const tool of mapping[capability]) + allowed.add(tool); + } + return Object.freeze(Object.fromEntries(OPENCODE_TOOLS.map(tool => [tool, allowed.has(tool)]))); +} + +/** @returns 对有副作用或外部访问的 OpenCode 工具给出显式 allow/deny。 */ +function openCodePermissions(tools: Readonly>): Readonly> { + return Object.freeze({ + edit: tools.edit ? 'allow' : 'deny', + bash: tools.bash ? 'allow' : 'deny', + webfetch: tools.webfetch ? 'allow' : 'deny', + task: tools.task ? 'allow' : 'deny', + }); +} + +/** OpenCode private Subagent renderer 的已验证 Platform-owned 输入。 */ +export interface OpenCodeAgentDocumentInput { + readonly description: string; + readonly body: string; + readonly tools: Readonly>; + readonly permission: Readonly>; +} + +/** 以 OpenCode workspace Subagent wire schema 渲染 Agent。 */ +export function renderOpenCodeAgent(input: OpenCodeAgentDocumentInput): string { + return markdownWithFrontmatter({ + description: input.description, + mode: 'subagent', + tools: input.tools, + permission: input.permission, + }, input.body); +} + +/** 将 OpenCode 私有 Component 映射为同一 Subagent renderer 输入。 */ +export function openCodeNativeAgentDocument(component: OpenCodeNativeAgentComponent): OpenCodeAgentDocumentInput { + const tools = Object.freeze(Object.fromEntries(OPENCODE_TOOLS.map(tool => [tool, component.tools?.[tool] ?? false]))); + const permission = Object.freeze({ + edit: component.permission?.edit ?? (tools.edit ? 'allow' : 'deny'), + bash: component.permission?.bash ?? (tools.bash ? 'allow' : 'deny'), + webfetch: component.permission?.webfetch ?? (tools.webfetch ? 'allow' : 'deny'), + task: component.permission?.task ?? (tools.task ? 'allow' : 'deny'), + }); + return Object.freeze({ description: component.description, body: component.body, tools, permission }); +} + +/** 把 canonical Commands、Skills 与 Agents 转换为 OpenCode workspace Assets。 */ +export async function createOpenCodeComponents( + project: CanonicalProject, + assets: AssetService, +): Promise { + /** output 只包含 Platform 自有 bytes 和 Core 授权的 Skill auxiliary refs。 */ + const output: PackageAssetInput[] = []; + /** compatibility 精确覆盖三类 canonical Component。 */ + const compatibility: CompatibilityInput[] = []; + for (const command of project.commands) { + /** Command Markdown 使用 OpenCode 原生 workspace 目录和参数占位符。 */ + const asset = await assets.fromBytes({ + bytes: markdownWithFrontmatter({ description: command.description }, command.body.replaceAll('{{arguments}}', '$ARGUMENTS')), + origin: { operation: 'component-command', subjects: [`command:${command.id}`] }, + }); + output.push(Object.freeze({ path: `.opencode/commands/${command.id}.md`, asset })); + compatibility.push(Object.freeze({ + subject: `command:${command.id}`, + capability: 'component', + level: 'native', + reason: 'OpenCode supports native workspace Commands and the $ARGUMENTS placeholder.', + })); + if (command.argumentHint !== undefined) { + compatibility.push(Object.freeze({ + subject: `command:${command.id}`, + capability: 'argument-hint', + level: 'degraded', + transformation: 'argument-hint-omitted', + reason: 'OpenCode Command metadata has no verified argument hint field.', + })); + } + } + for (const skill of project.skills) { + /** Skill 主文档使用 OpenCode 原生 Agent Skill 结构。 */ + const asset = await assets.fromBytes({ + bytes: markdownWithFrontmatter({ name: skill.id, description: skill.description }, skill.body), + origin: { operation: 'component-skill', subjects: [`skill:${skill.id}`] }, + }); + output.push(Object.freeze({ path: `.opencode/skills/${skill.id}/SKILL.md`, asset })); + for (const auxiliary of skill.auxiliaryFiles) + output.push(Object.freeze({ path: `.opencode/skills/${skill.id}/${auxiliary.path}`, asset: auxiliary.asset })); + compatibility.push(Object.freeze({ + subject: `skill:${skill.id}`, + capability: 'component', + level: 'native', + reason: 'OpenCode supports native workspace Agent Skills.', + })); + if (!skill.invocation.user || !skill.invocation.model) { + compatibility.push(Object.freeze({ + subject: `skill:${skill.id}`, + capability: 'invocation', + level: 'degraded', + transformation: 'invocation-switches-omitted', + reason: 'OpenCode has no verified independent user and model invocation switches for Skills.', + })); + } + } + for (const agent of project.agents) { + /** tools 是 portable capability 的原生完整开关映射。 */ + const tools = openCodeTools(agent.capabilities); + /** Agent Markdown 使用 OpenCode 原生 Subagent 配置。 */ + const asset = await assets.fromBytes({ + bytes: renderOpenCodeAgent({ + description: agent.description, + body: agent.body, + tools, + permission: openCodePermissions(tools) as Readonly>, + }), + origin: { operation: 'component-agent', subjects: [`agent:${agent.id}`] }, + }); + output.push(Object.freeze({ path: `.opencode/agents/${agent.id}.md`, asset })); + compatibility.push( + Object.freeze({ + subject: `agent:${agent.id}`, + capability: 'component', + level: 'native', + reason: 'OpenCode supports native workspace Subagents.', + }), + Object.freeze({ + subject: `agent:${agent.id}`, + capability: 'agent.capabilities', + level: 'transform', + transformation: 'native-tools-and-permissions', + reason: 'OpenCode enforces canonical capabilities through native tools and permission fields.', + }), + ); + if (agent.model !== 'inherit') { + compatibility.push(Object.freeze({ + subject: `agent:${agent.id}`, + capability: 'agent.model', + level: 'degraded', + transformation: 'platform-default-model', + reason: 'OpenCode has no stable mapping for canonical abstract model classes.', + })); + } + } + return Object.freeze({ assets: Object.freeze(output), compatibility: Object.freeze(compatibility) }); +} diff --git a/packages/platforms/opencode/src/package/config-document.ts b/packages/platforms/opencode/src/package/config-document.ts new file mode 100644 index 0000000..b0463a3 --- /dev/null +++ b/packages/platforms/opencode/src/package/config-document.ts @@ -0,0 +1,83 @@ +import type { + JsonObject, + MetadataDispositionInput, + PackageDocumentInput, + PluginMetadata, +} from '@tokenroll/acplugin/sdk'; +import type { OpenCodePlatformOptions, OpenCodeWorkspaceOptions } from '../types.js'; + +/** OpenCode workspace 配置的稳定逻辑 Document ID。 */ +export const WORKSPACE_CONFIG_ID = 'workspace-config'; + +/** OpenCode workspace 配置相对于交付根的固定路径。 */ +export const WORKSPACE_CONFIG_PATH = 'opencode.json'; + +/** OpenCode 官方 JSON Schema URL。 */ +const OPENCODE_SCHEMA_URL = 'https://opencode.ai/config.json'; + +/** 校验 OpenCode Platform 选项并拒绝任意 workspace 配置透传。 */ +export function validatePlatformOptions(options: OpenCodePlatformOptions): void { + /** Platform 顶层只允许 strict 和受控 workspace 子对象。 */ + const allowed = new Set(['strict', 'workspace']); + for (const field of Object.keys(options)) { + if (!allowed.has(field)) + throw new TypeError(`Unknown OpenCode Platform option "${field}".`); + } + if (options.strict !== undefined && typeof options.strict !== 'boolean') + throw new TypeError('OpenCode strict must be a boolean.'); + if (options.workspace !== undefined) { + if (options.workspace === null || typeof options.workspace !== 'object' || Array.isArray(options.workspace)) + throw new TypeError('OpenCode workspace must be a plain object.'); + for (const field of Object.keys(options.workspace)) { + if (field !== 'schema') + throw new TypeError(`Unknown OpenCode workspace option "${field}".`); + } + if (options.workspace.schema !== undefined && typeof options.workspace.schema !== 'boolean') + throw new TypeError('OpenCode workspace.schema must be a boolean.'); + } +} + +/** @returns OpenCode workspace 对实际 canonical metadata 的完整 omitted disposition。 */ +function metadataDispositions(metadata: PluginMetadata): readonly MetadataDispositionInput[] { + /** fields 与 Core metadata coverage 使用相同的字段粒度。 */ + const fields = ['name', 'version', 'description']; + for (const field of ['displayName', 'homepage', 'repository', 'license'] as const) { + if (metadata[field] !== undefined) + fields.push(field); + } + if (metadata.author !== undefined) { + fields.push('author.name'); + if (metadata.author.email !== undefined) + fields.push('author.email'); + if (metadata.author.url !== undefined) + fields.push('author.url'); + } + if (metadata.keywords.length > 0) + fields.push('keywords'); + return Object.freeze(fields.map(field => Object.freeze({ + field, + disposition: 'omitted' as const, + reason: 'OpenCode delivery is a workspace overlay, not a static Plugin Manifest.', + }))); +} + +/** 创建由 Core codec 按需物化且只开放 MCP 根字段的 workspace Document。 */ +export function createWorkspaceDocument(input: { + readonly metadata: PluginMetadata; + readonly options: Readonly; +}): { readonly document: PackageDocumentInput; readonly metadata: readonly MetadataDispositionInput[] } { + /** workspaceOptions 已由工厂边界校验并由 Core 复制冻结。 */ + const workspaceOptions = input.options.workspace as OpenCodeWorkspaceOptions | undefined; + /** 空对象配合 omit-if-empty 避免覆盖消费项目已有 opencode.json。 */ + const value: JsonObject = workspaceOptions?.schema === true ? { $schema: OPENCODE_SCHEMA_URL } : {}; + /** document 是 OpenCode Platform 唯一拥有的结构化配置。 */ + const document: PackageDocumentInput = Object.freeze({ + id: WORKSPACE_CONFIG_ID, + path: WORKSPACE_CONFIG_PATH, + format: 'json', + value, + emission: 'omit-if-empty', + extensionPoints: Object.freeze([Object.freeze(['mcp'] as const)]), + }); + return Object.freeze({ document, metadata: metadataDispositions(input.metadata) }); +} diff --git a/packages/platforms/opencode/src/package/validator.ts b/packages/platforms/opencode/src/package/validator.ts new file mode 100644 index 0000000..49e96dc --- /dev/null +++ b/packages/platforms/opencode/src/package/validator.ts @@ -0,0 +1,183 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { JsonValue, ValidatePackageContext } from '@tokenroll/acplugin/sdk'; +import { WORKSPACE_CONFIG_PATH } from './config-document.js'; + +/** OpenCode validator 只消费 SDK 的最终 Package candidate Context。 */ +type PlatformValidateContext = ValidatePackageContext; + +/** OpenCode workspace 配置由 Platform/Extension 允许生成的根字段。 */ +const CONFIG_FIELDS = new Set(['$schema', 'mcp']); + +/** OpenCode local MCP descriptor 允许的完整字段。 */ +const LOCAL_MCP_FIELDS = new Set(['type', 'command', 'environment', 'enabled']); + +/** OpenCode remote MCP descriptor 允许的完整字段。 */ +const REMOTE_MCP_FIELDS = new Set(['type', 'url', 'headers', 'oauth', 'enabled']); + +/** OpenCode MCP Server key 使用稳定 lowercase-kebab 规则。 */ +const MCP_SERVER_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + +/** JSON 对象的运行时只读索引类型。 */ +type JsonRecord = Record; + +/** + * 判断未知值是否为非数组 JSON 对象。 + * + * @param value 从候选配置解析的未知值。 + * @returns 可以按字段读取时返回 true。 + */ +function isRecord(value: unknown): value is JsonRecord { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * 向 Core 提交 OpenCode 候选校验错误。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param code 稳定诊断码。 + * @param message 不包含宿主绝对路径的错误信息。 + */ +function report(context: PlatformValidateContext, code: string, message: string): void { + context.diagnostics.report({ code, severity: 'error', message }); +} + +/** @returns 安全 workspace-relative POSIX 引用对应的 Asset key。 */ +function workspaceAssetPath(value: string): string | undefined { + if (!value.startsWith('./') || value.includes('\\') || value.includes('\0')) + return undefined; + /** relative 必须是非空且不含空、dot 或 parent segment 的路径。 */ + const relative = value.slice(2); + /** 分段校验避免任一 segment 逃逸 workspace。 */ + const segments = relative.split('/'); + if (relative.length === 0 || segments.some(segment => segment === '' || segment === '.' || segment === '..')) + return undefined; + return relative; +} + +/** 校验 Platform 将要运行或连接的 OpenCode MCP wire data。 */ +async function validateMcp( + context: PlatformValidateContext, + assets: ReadonlySet, + value: JsonRecord, +): Promise { + for (const [id, candidate] of Object.entries(value)) { + if (!MCP_SERVER_ID_PATTERN.test(id) || !isRecord(candidate)) { + report(context, 'OPENCODE_MCP_SERVER_INVALID', `OpenCode MCP Server "${id}" must use lowercase kebab-case and map to an object.`); + continue; + } + /** type 决定当前 descriptor 的 exact field set。 */ + const fields = candidate.type === 'local' + ? LOCAL_MCP_FIELDS + : candidate.type === 'remote' + ? REMOTE_MCP_FIELDS + : undefined; + if (fields !== undefined) { + for (const field of Object.keys(candidate)) { + if (!fields.has(field)) + report(context, 'OPENCODE_MCP_FIELD_UNKNOWN', `Unknown OpenCode MCP field "${field}" on Server "${id}".`); + } + } + if (candidate.enabled !== undefined && typeof candidate.enabled !== 'boolean') + report(context, 'OPENCODE_MCP_ENABLED_INVALID', `OpenCode MCP Server "${id}" enabled must be boolean.`); + if (candidate.type === 'local') { + /** local command 固定为 node 与当前 Server ID 的唯一 canonical bundle path。 */ + const command = candidate.command; + if (!Array.isArray(command) || command.length !== 2 || command.some(argument => typeof argument !== 'string')) { + report(context, 'OPENCODE_MCP_LOCAL_COMMAND_INVALID', `OpenCode local MCP Server "${id}" requires exactly two string command arguments.`); + continue; + } + /** ID、目标路径和可执行 mode 共同封闭最终候选协议。 */ + const expected = `.opencode/mcp/${id}/server.mjs`; + /** command[1] 已由完整字符串数组检查收窄。 */ + const entry = workspaceAssetPath(command[1] as string); + /** 只有当前 ID 的固定路径才可作为本地 Server。 */ + let executable = false; + if (entry === expected && assets.has(expected)) { + try { + /** candidate 是 Core 临时物化且已闭包校验的只读树。 */ + const stat = await fs.lstat(path.join(context.candidate.root, expected)); + executable = stat.isFile() && !stat.isSymbolicLink() && (stat.mode & 0o777) === 0o755; + } catch { + executable = false; + } + } + if (command[0] !== 'node' || entry !== expected || !executable) + report(context, 'OPENCODE_MCP_LOCAL_ENTRY_INVALID', `OpenCode local MCP Server "${id}" must reference its executable canonical workspace Asset.`); + if (candidate.environment !== undefined + && (!isRecord(candidate.environment) + || Object.entries(candidate.environment).some(([key, entryValue]) => key.trim().length === 0 || typeof entryValue !== 'string'))) { + report(context, 'OPENCODE_MCP_ENVIRONMENT_INVALID', `OpenCode local MCP Server "${id}" environment must map non-empty names to strings.`); + } + } else if (candidate.type === 'remote') { + try { + /** remote URL 只接受无内联凭据的 HTTP(S) 地址。 */ + if (typeof candidate.url !== 'string') + throw new TypeError('URL must be a string.'); + /** url 是已经通过字符串边界的标准 URL 解析结果。 */ + const url = new URL(candidate.url); + if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username !== '' || url.password !== '') + throw new TypeError('Unsafe URL.'); + } catch { + report(context, 'OPENCODE_MCP_REMOTE_URL_INVALID', `OpenCode remote MCP Server "${id}" requires an HTTP(S) URL without credentials.`); + } + if (candidate.headers !== undefined + && (!isRecord(candidate.headers) + || Object.entries(candidate.headers).some(([key, header]) => key.trim().length === 0 || typeof header !== 'string'))) { + report(context, 'OPENCODE_MCP_HEADERS_INVALID', `OpenCode remote MCP Server "${id}" headers must map non-empty names to strings.`); + } + if (candidate.oauth !== undefined) { + if (!isRecord(candidate.oauth)) { + report(context, 'OPENCODE_MCP_OAUTH_INVALID', `OpenCode remote MCP Server "${id}" oauth must be an object.`); + } else { + for (const field of Object.keys(candidate.oauth)) { + if (field !== 'scope') + report(context, 'OPENCODE_MCP_OAUTH_FIELD_UNKNOWN', `Unknown OpenCode MCP OAuth field "${field}" on Server "${id}".`); + } + if (candidate.oauth.scope !== undefined + && (typeof candidate.oauth.scope !== 'string' || candidate.oauth.scope.trim().length === 0)) { + report(context, 'OPENCODE_MCP_OAUTH_INVALID', `OpenCode remote MCP Server "${id}" oauth.scope must be a non-empty string.`); + } + } + } + } else { + report(context, 'OPENCODE_MCP_SERVER_TYPE_INVALID', `OpenCode MCP Server "${id}" must be local or remote.`); + } + } +} + +/** + * 校验 OpenCode workspace 只包含受控资源和按需配置。 + * + * @param context Platform 提供的已物化候选交付单元。 + */ +export async function validateOpenCodePackage(context: PlatformValidateContext): Promise { + /** 当前候选 Workspace Package 的规范 Asset 路径集合。 */ + const assets = new Set(context.candidate.unit.assets.map(asset => asset.path)); + if (assets.has('.cursor-plugin/plugin.json') || assets.has('.claude-plugin/plugin.json')) { + report(context, 'OPENCODE_PLUGIN_MANIFEST_FORBIDDEN', 'OpenCode workspace delivery must not generate a Plugin manifest.'); + } + if (assets.has('package.json')) + report(context, 'OPENCODE_PACKAGE_JSON_FORBIDDEN', 'OpenCode workspace delivery must not generate a generic package.json.'); + if (!assets.has(WORKSPACE_CONFIG_PATH)) + return; + try { + /** 按需配置必须是只包含 Platform/Extension 所有字段的 JSON 对象。 */ + const value: unknown = JSON.parse(await fs.readFile(path.join(context.candidate.root, WORKSPACE_CONFIG_PATH), 'utf8')); + if (!isRecord(value)) + throw new TypeError('Config is not an object.'); + /** field 表示当前配置根字段,用于阻止任意消费工程配置注入。 */ + for (const field of Object.keys(value)) { + if (!CONFIG_FIELDS.has(field)) + report(context, 'OPENCODE_CONFIG_FIELD_UNKNOWN', `Unknown generated OpenCode config field "${field}".`); + } + if (value.mcp !== undefined) { + if (!isRecord(value.mcp)) + report(context, 'OPENCODE_MCP_CONFIG_INVALID', 'opencode.json.mcp must be an object.'); + else + await validateMcp(context, assets, value.mcp); + } + } catch { + report(context, 'OPENCODE_CONFIG_READ_FAILED', 'opencode.json must contain a valid JSON object.'); + } +} diff --git a/packages/platforms/opencode/src/types.ts b/packages/platforms/opencode/src/types.ts new file mode 100644 index 0000000..c2c1fcc --- /dev/null +++ b/packages/platforms/opencode/src/types.ts @@ -0,0 +1,33 @@ +/** OpenCode workspace 根配置的受控选项。 */ +export interface OpenCodeWorkspaceOptions { + /** 是否在按需生成的 opencode.json 中写入官方 JSON Schema URL。 */ + readonly schema?: boolean; +} + +/** 创建 OpenCode Platform 时可声明的公开选项。 */ +export interface OpenCodePlatformOptions { + /** 覆盖当前 Platform 的兼容性严格度。 */ + readonly strict?: boolean; + /** 只影响 acplugin 拥有的 workspace 配置文件,不允许任意透传。 */ + readonly workspace?: OpenCodeWorkspaceOptions; +} + +/** OpenCode Platform 接受的私有 Component union。 */ +export type OpenCodePackageComponent = OpenCodeNativeAgentComponent; + +/** + * OpenCode 原生 workspace Subagent contribution。 + * + * tools/permission 使用 OpenCode 本身的 wire model;不复用任何其他 Platform 的 + * Agent 类型、模型等级或角色抽象。 + */ +export type OpenCodeNativeAgentComponent = import('@tokenroll/acplugin/sdk').JsonObject & Readonly<{ + readonly kind: 'native-agent'; + readonly id: string; + readonly description: string; + readonly body: string; + /** OpenCode accepts a sparse map; omitted keys use the Platform defaults. */ + readonly tools?: Readonly>>; + /** Permissions are likewise optional per tool and may be supplied independently. */ + readonly permission?: Readonly>>; +}>; diff --git a/packages/platforms/opencode/test/golden/opencode.json b/packages/platforms/opencode/test/golden/opencode.json new file mode 100644 index 0000000..720ece5 --- /dev/null +++ b/packages/platforms/opencode/test/golden/opencode.json @@ -0,0 +1,3 @@ +{ + "$schema": "https://opencode.ai/config.json" +} diff --git a/packages/platforms/opencode/test/platform.test.ts b/packages/platforms/opencode/test/platform.test.ts new file mode 100644 index 0000000..1bcb6a1 --- /dev/null +++ b/packages/platforms/opencode/test/platform.test.ts @@ -0,0 +1,511 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + resolveKernelConfig, + runKernelBuildSession, +} from '@acplugin/core'; +import { + defineExtension, + type AcpluginExtension, + type BytesAssetRef, + type JsonValue, + type PlatformContributor, +} from '@tokenroll/acplugin/sdk'; +import { openCode, type OpenCodePackageComponent } from '../src/index.js'; +import { WORKSPACE_CONFIG_PATH } from '../src/package/config-document.js'; + +/** 测试结束后统一删除的临时工程根目录。 */ +const temporaryRoots: string[] = []; + +/** OpenCode 配置 Golden 的固定目录。 */ +const goldenRoot = path.join(import.meta.dirname, 'golden'); + +/** 创建包含最小配置占位符且登记清理的工程。 */ +async function temporaryProject(): Promise { + /** 当前用例独占的工程根目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-opencode-platform-')); + temporaryRoots.push(root); + await fs.mkdir(path.join(root, 'src'), { recursive: true }); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), 'export default {}\n'); + return root; +} + +/** 写入 OpenCode 三类原生 workspace Component 与 Skill 辅助文件。 */ +async function writeCompleteProject(root: string): Promise { + await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/skills/review/references'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/agents'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/commands/release.md'), '---\ndescription: Prepare a release.\n---\nPrepare release {{arguments}}.\n'); + await fs.writeFile(path.join(root, 'src/skills/review/SKILL.md'), '---\ndescription: Review a change.\n---\nReview the change.\n'); + await fs.writeFile(path.join(root, 'src/skills/review/references/checklist.md'), 'Review checklist.\n'); + await fs.writeFile(path.join(root, 'src/agents/reviewer.md'), `--- +description: Review code. +model: inherit +capabilities: [filesystem:read, search] +--- +Review code. +`); +} + +/** 执行只包含 OpenCode 的真实 Kernel v2 BuildSession。 */ +async function run(input: { + readonly root: string; + readonly platform?: ReturnType; + readonly extensions?: readonly AcpluginExtension[]; + readonly command?: 'validate' | 'inspect' | 'build'; + readonly commit?: boolean; +}) { + /** command 决定生命周期语义,commit 只允许 build 使用。 */ + const command = input.command ?? 'build'; + /** resolved 使用公开配置相同的 Kernel resolver。 */ + const resolved = resolveKernelConfig({ + name: 'release-tools', + version: '1.2.3', + description: 'Release workflow tools.', + public: false, + platforms: [input.platform ?? openCode({ workspace: { schema: true } })], + extensions: input.extensions ?? [], + }, { + projectRoot: input.root, + configFile: path.join(input.root, 'acplugin.config.ts'), + command, + mode: 'production', + }); + expect(resolved.diagnostics).toEqual([]); + return (await runKernelBuildSession({ + config: resolved.config!, + frameworkVersion: 'test', + commit: command === 'build' && (input.commit ?? true), + })).report; +} + +/** 创建占用 `workspace-config.mcp` 且可追加一个根 Asset 的测试 Extension。 */ +function mcpContribution(input: { + readonly id: string; + readonly value: JsonValue; + readonly asset?: { readonly path: string; readonly bytes: string; readonly mode?: 0o644 | 0o755 }; +}): AcpluginExtension { + return defineExtension, Record, Record, { readonly asset?: BytesAssetRef }>({ + id: input.id, + apiVersion: '1', + options: {}, + resourceRoots: [], + /** 每轮创建独立且不读取其他 Extension state 的 Session。 */ + createSession: () => ({ + /** 空状态表示 Fixture 已发现。 */ + discover: () => ({}), + /** capability 声明要求 Contributor 完整覆盖。 */ + validate: (_context, state) => ({ + state, + subjects: [{ subject: `fixture:${input.id}`, capabilities: ['delivery'] }], + }), + /** 可选 bytes 只通过 owner-scoped Asset Service 签发。 */ + async build({ assets }) { + if (input.asset === undefined) + return { state: {} }; + /** asset 是 Contributor 后续唯一能追加的受管引用。 */ + const asset = await assets.fromBytes({ + bytes: input.asset.bytes, + mode: input.asset.mode ?? 0o644, + origin: { operation: 'opencode-fixture', subjects: [`fixture:${input.id}`] }, + }); + return { state: { asset } }; + }, + contributors: [{ + platform: 'opencode', + platformApiVersion: '1', + /** Contributor 只填 MCP 声明点并可追加自己拥有的 Asset。 */ + contribute: (_context, built) => ({ + documentFields: [{ document: 'workspace-config', path: ['mcp'], value: input.value }], + ...(input.asset === undefined || built.asset === undefined + ? {} + : { assets: [{ path: input.asset.path, asset: built.asset }] }), + compatibility: [{ + subject: `fixture:${input.id}`, + capability: 'delivery', + level: 'native', + reason: 'The fixture is delivered through the OpenCode Package contribution contract.', + }], + }), + }], + }), + }); +} + +/** 创建只由 OpenCode Platform Component transport 交付 Native Agent 的中立 Extension。 */ +function nativeAgentContribution(input: { + readonly id: string; + readonly agents: readonly Record[]; +}): AcpluginExtension { + /** OpenCode payload union 只在此 contributor 与 Platform finalization 边界存在。 */ + const contributor: PlatformContributor, OpenCodePackageComponent> = { + platform: 'opencode', + platformApiVersion: '1', + contribute: () => ({ + components: input.agents.map(agent => ({ + subject: `fixture:${input.id}`, + value: agent as OpenCodePackageComponent, + })), + compatibility: [{ + subject: `fixture:${input.id}`, + capability: 'delivery', + level: 'native', + reason: 'The fixture is delivered as a native OpenCode Subagent.', + }], + }), + }; + return defineExtension({ + id: input.id, + apiVersion: '1', + resourceRoots: [], + createSession: () => ({ + discover: () => ({}), + validate: (_context, discovered) => ({ + state: discovered, + subjects: [{ subject: `fixture:${input.id}`, capabilities: ['delivery'] }], + }), + build: (_context, validated) => ({ state: validated }), + contributors: [contributor], + }), + }); +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('OpenCode Platform Package API', () => { + it('exposes sparse tool and permission maps in the public component type', () => { + /** The runtime accepts a subset; consumers should not need a type assertion. */ + const component: OpenCodePackageComponent = { + kind: 'native-agent', + id: 'sparse-agent', + description: 'Sparse Agent.', + body: 'Run the task.', + tools: { read: true }, + permission: { edit: 'deny' }, + }; + expect(component.tools).toEqual({ read: true }); + expect(component.permission).toEqual({ edit: 'deny' }); + }); + + it('builds a first-class workspace Package with native Components and config golden', async () => { + /** root 覆盖全部原生 workspace Resources。 */ + const root = await temporaryProject(); + await writeCompleteProject(root); + /** report 来自同一 Package lifecycle 和受管事务。 */ + const report = await run({ root }); + /** output 是 workspace overlay 根而非 Plugin root。 */ + const output = path.join(root, 'dist/opencode/workspace'); + /** discovered 模拟 OpenCode 对标准 workspace 目录的资源发现。 */ + const discovered = (await fs.readdir(path.join(output, '.opencode'), { recursive: true })) + .map(entry => String(entry).split(path.sep).join('/')) + .filter(entry => entry.endsWith('.md')) + .sort(); + + expect(report.success, JSON.stringify(report.diagnostics, null, 2)).toBe(true); + expect(report.packages).toContainEqual(expect.objectContaining({ + platform: 'opencode', id: 'workspace', type: 'workspace', role: 'primary', validated: true, + })); + await expect(fs.readFile(path.join(output, WORKSPACE_CONFIG_PATH))).resolves.toEqual( + await fs.readFile(path.join(goldenRoot, WORKSPACE_CONFIG_PATH)), + ); + expect(discovered).toEqual([ + 'agents/reviewer.md', + 'commands/release.md', + 'skills/review/SKILL.md', + 'skills/review/references/checklist.md', + ]); + await expect(fs.readFile(path.join(output, '.opencode/commands/release.md'), 'utf8')).resolves.toContain('$ARGUMENTS'); + await expect(fs.readFile(path.join(output, '.opencode/agents/reviewer.md'), 'utf8')).resolves.toContain('permission:'); + await expect(fs.access(path.join(output, 'package.json'))).rejects.toThrow(); + await expect(fs.access(path.join(output, '.cursor-plugin'))).rejects.toThrow(); + expect(report.compatibility).toEqual(expect.arrayContaining([ + expect.objectContaining({ subject: 'command:release', capability: 'component', level: 'native' }), + expect.objectContaining({ subject: 'skill:review', capability: 'component', level: 'native' }), + expect.objectContaining({ subject: 'agent:reviewer', capability: 'agent.capabilities', level: 'transform' }), + ])); + expect(report.metadata).toContainEqual(expect.objectContaining({ field: 'name', disposition: 'omitted' })); + }); + + it('does not materialize an empty workspace config', async () => { + /** root 没有 Component,默认 Platform 选项也不产生配置字段。 */ + const root = await temporaryProject(); + /** report 仍创建有效但为空的 workspace Package。 */ + const report = await run({ root, platform: openCode() }); + + expect(report.success, JSON.stringify(report.diagnostics, null, 2)).toBe(true); + expect(report.packages).toContainEqual(expect.objectContaining({ id: 'workspace', assets: [] })); + await expect(fs.access(path.join(root, 'dist/opencode/workspace/opencode.json'))).rejects.toThrow(); + }); + + it('renders Platform-owned Native Agent contributions without writing workspace configuration', async () => { + /** private Agent 不要求也不能通过 manifest/config patch 获得发现能力。 */ + const root = await temporaryProject(); + const report = await run({ + root, + platform: openCode(), + extensions: [nativeAgentContribution({ + id: 'private-fixture', + agents: [{ + kind: 'native-agent', id: 'observer', description: 'Observe the project.', body: 'Observe.', + tools: { read: true, glob: true }, permission: { edit: 'deny', bash: 'deny' }, + }], + })], + }); + const output = path.join(root, 'dist/opencode/workspace'); + const asset = report.packages[0]!.assets.find(candidate => candidate.path === '.opencode/agents/observer.md'); + + expect(report.success, JSON.stringify(report.diagnostics, null, 2)).toBe(true); + await expect(fs.readFile(path.join(output, '.opencode/agents/observer.md'), 'utf8')).resolves.toContain('mode: subagent'); + await expect(fs.access(path.join(output, WORKSPACE_CONFIG_PATH))).rejects.toThrow(); + expect(asset).toMatchObject({ + owner: 'platform:opencode', + origin: { contributors: [{ owner: 'extension:private-fixture', subject: 'fixture:private-fixture' }] }, + }); + }); + + it('rejects malformed and colliding Native Agent contributions independently of Extension order', async () => { + /** canonical 与 private Agent 使用同一个 .opencode/agents namespace。 */ + const canonicalRoot = await temporaryProject(); + await fs.mkdir(path.join(canonicalRoot, 'src/agents'), { recursive: true }); + await fs.writeFile(path.join(canonicalRoot, 'src/agents/reviewer.md'), '---\ndescription: Reviewer.\n---\nReview.\n'); + const canonical = await run({ + root: canonicalRoot, + command: 'validate', + commit: false, + extensions: [nativeAgentContribution({ + id: 'canonical-collision', + agents: [{ kind: 'native-agent', id: 'reviewer', description: 'Duplicate.', body: 'Duplicate.' }], + })], + }); + expect(canonical.success).toBe(false); + expect(canonical.diagnostics).toContainEqual(expect.objectContaining({ + code: 'OPENCODE_COMPONENT_CONTRIBUTION_COLLISION', phase: 'finalize', platform: 'opencode', + })); + + /** unknown field 保持 Platform-owned wire schema 的最终验证职责。 */ + const malformedRoot = await temporaryProject(); + const malformed = await run({ + root: malformedRoot, + command: 'validate', + commit: false, + extensions: [nativeAgentContribution({ + id: 'malformed-agent', + agents: [{ kind: 'native-agent', id: 'invalid', description: 'Invalid.', body: 'Invalid.', unsupported: true }], + })], + }); + expect(malformed.success).toBe(false); + expect(malformed.diagnostics).toContainEqual(expect.objectContaining({ + code: 'OPENCODE_COMPONENT_CONTRIBUTION_INVALID', phase: 'finalize', platform: 'opencode', + })); + + /** owner/subject 排序固定后,duplicate 失败不受 Extension 输入排列影响。 */ + const ordered = [ + nativeAgentContribution({ id: 'zeta-fixture', agents: [{ kind: 'native-agent', id: 'same', description: 'Same.', body: 'Same.' }] }), + nativeAgentContribution({ id: 'alpha-fixture', agents: [{ kind: 'native-agent', id: 'same', description: 'Same.', body: 'Same.' }] }), + ]; + const first = await run({ root: await temporaryProject(), command: 'validate', commit: false, extensions: ordered }); + const second = await run({ root: await temporaryProject(), command: 'validate', commit: false, extensions: [...ordered].reverse() }); + expect(first.success).toBe(false); + expect(second.success).toBe(false); + expect(first.diagnostics).toEqual(second.diagnostics); + expect(first.diagnostics).toContainEqual(expect.objectContaining({ + code: 'OPENCODE_COMPONENT_CONTRIBUTION_COLLISION', phase: 'finalize', platform: 'opencode', + })); + }); + + it('accepts remote/local MCP contribution and validates local Asset references', async () => { + /** root 可为空,MCP Contribution 会让 omit-if-empty Document 实际物化。 */ + const root = await temporaryProject(); + /** mcp 同时覆盖安全 remote URL 和候选内 local server。 */ + const mcp = mcpContribution({ + id: 'mcp-fixture', + value: { + docs: { type: 'remote', url: 'https://example.com/mcp', enabled: true }, + local: { type: 'local', command: ['node', './.opencode/mcp/local/server.mjs'], enabled: true }, + }, + asset: { path: '.opencode/mcp/local/server.mjs', bytes: 'process.exit(0);\n', mode: 0o755 }, + }); + /** report 必须通过 Core merge 与最终 OpenCode wire validation。 */ + const report = await run({ root, extensions: [mcp] }); + /** config 是 Core JSON codec 产生的最终对象。 */ + const config = JSON.parse(await fs.readFile(path.join(root, 'dist/opencode/workspace/opencode.json'), 'utf8')); + + expect(report.success, JSON.stringify(report.diagnostics, null, 2)).toBe(true); + expect(config).toHaveProperty('mcp.local.command.1', './.opencode/mcp/local/server.mjs'); + expect(report.packages[0]?.assets).toContainEqual(expect.objectContaining({ + path: '.opencode/mcp/local/server.mjs', owner: 'extension:mcp-fixture', + })); + }); + + it('rejects MCP point collisions and malformed merged wire data', async () => { + /** collisionRoot 的两个 Extension 无序占用同一个精确 Document point。 */ + const collisionRoot = await temporaryProject(); + /** first 和 second 的 ID 不影响冲突结果。 */ + const first = mcpContribution({ id: 'first-mcp', value: { first: { type: 'remote', url: 'https://example.com/first' } } }); + /** second 占用相同 workspace-config.mcp 字段。 */ + const second = mcpContribution({ id: 'second-mcp', value: { second: { type: 'remote', url: 'https://example.com/second' } } }); + /** collision 必须由 Core merge 拒绝而不是依赖 Extension 顺序。 */ + const collision = await run({ root: collisionRoot, command: 'validate', extensions: [first, second], commit: false }); + expect(collision.diagnostics).toContainEqual(expect.objectContaining({ + code: 'PLATFORM_CONTRIBUTION_FAILED', platform: 'opencode', phase: 'contribute', + })); + + /** malformedRoot 的 local command 逃逸且没有候选 Asset。 */ + const malformedRoot = await temporaryProject(); + /** malformed 仍是 JSON object,因此必须由细粒度 wire validator 拒绝。 */ + const malformed = mcpContribution({ + id: 'malformed-mcp', + value: { local: { type: 'local', command: ['node', '../escape.mjs'] } }, + }); + /** malformedReport 保留最终 Platform 诊断。 */ + const malformedReport = await run({ root: malformedRoot, command: 'validate', extensions: [malformed], commit: false }); + expect(malformedReport.diagnostics).toContainEqual(expect.objectContaining({ + code: 'OPENCODE_MCP_LOCAL_ENTRY_INVALID', platform: 'opencode', phase: 'platform-validate', + })); + + /** 最终 validator 必须把 Server ID、两段 command、固定 suffix 和 0755 mode 绑定为一体。 */ + const hostileCases = [ + { + id: 'wrong-id', + value: { local: { type: 'local', command: ['node', './.opencode/mcp/other/server.mjs'] } }, + asset: { path: '.opencode/mcp/other/server.mjs', bytes: 'process.exit(0);\n', mode: 0o755 as const }, + code: 'OPENCODE_MCP_LOCAL_ENTRY_INVALID', + }, + { + id: 'wrong-suffix', + value: { local: { type: 'local', command: ['node', './.opencode/mcp/local/server.js'] } }, + asset: { path: '.opencode/mcp/local/server.js', bytes: 'process.exit(0);\n', mode: 0o755 as const }, + code: 'OPENCODE_MCP_LOCAL_ENTRY_INVALID', + }, + { + id: 'extra-argument', + value: { local: { type: 'local', command: ['node', './.opencode/mcp/local/server.mjs', '--unsafe'] } }, + asset: { path: '.opencode/mcp/local/server.mjs', bytes: 'process.exit(0);\n', mode: 0o755 as const }, + code: 'OPENCODE_MCP_LOCAL_COMMAND_INVALID', + }, + { + id: 'non-executable', + value: { local: { type: 'local', command: ['node', './.opencode/mcp/local/server.mjs'] } }, + asset: { path: '.opencode/mcp/local/server.mjs', bytes: 'process.exit(0);\n', mode: 0o644 as const }, + code: 'OPENCODE_MCP_LOCAL_ENTRY_INVALID', + }, + ] as const; + for (const hostile of hostileCases) { + /** 每个 hostile Contribution 使用独立候选,证明失败不依赖冲突顺序。 */ + const root = await temporaryProject(); + /** Extension 只使用公开 SDK 签发 Bytes Asset 和 Contribution。 */ + const report = await run({ + root, + command: 'validate', + extensions: [mcpContribution({ id: hostile.id, value: hostile.value, asset: hostile.asset })], + commit: false, + }); + expect(report.success).toBe(false); + expect(report.diagnostics).toContainEqual(expect.objectContaining({ + code: hostile.code, platform: 'opencode', phase: 'platform-validate', + })); + } + + /** 已有 canonical Component Asset 也不能被借作某个 local MCP 的入口。 */ + const borrowedRoot = await temporaryProject(); + await writeCompleteProject(borrowedRoot); + /** command 指向真实存在但不属于 MCP canonical path 的 Asset。 */ + const borrowed = await run({ + root: borrowedRoot, + command: 'validate', + extensions: [mcpContribution({ + id: 'borrowed-asset', + value: { local: { type: 'local', command: ['node', './.opencode/commands/release.md'] } }, + })], + commit: false, + }); + expect(borrowed.diagnostics).toContainEqual(expect.objectContaining({ + code: 'OPENCODE_MCP_LOCAL_ENTRY_INVALID', platform: 'opencode', phase: 'platform-validate', + })); + + /** remoteRoot 隔离验证 remote Server 的字段形状和未知字段。 */ + const remoteRoot = await temporaryProject(); + /** malformedRemote 同时包含非法 headers 与未确认字段。 */ + const malformedRemote = mcpContribution({ + id: 'malformed-remote-mcp', + value: { + docs: { type: 'remote', url: 'https://example.com/mcp', headers: 42, extra: true }, + }, + }); + /** remoteReport 必须保留两个稳定的最终 wire 诊断。 */ + const remoteReport = await run({ root: remoteRoot, command: 'validate', extensions: [malformedRemote], commit: false }); + expect(remoteReport.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'OPENCODE_MCP_HEADERS_INVALID', platform: 'opencode', phase: 'platform-validate' }), + expect.objectContaining({ code: 'OPENCODE_MCP_FIELD_UNKNOWN', platform: 'opencode', phase: 'platform-validate' }), + ])); + }); + + it('rejects generic package and Plugin manifest Assets in a workspace delivery', async () => { + /** packageRoot 的 Extension 同时填合法 MCP 点并追加禁止的 package.json。 */ + const packageRoot = await temporaryProject(); + /** packageExtension 证明最终 validator 不依赖 Asset owner。 */ + const packageExtension = mcpContribution({ + id: 'package-injection', + value: {}, + asset: { path: 'package.json', bytes: '{}\n' }, + }); + /** packageReport 必须在候选边界拒绝 Plugin/package 语义泄漏。 */ + const packageReport = await run({ root: packageRoot, command: 'validate', extensions: [packageExtension], commit: false }); + expect(packageReport.diagnostics).toContainEqual(expect.objectContaining({ code: 'OPENCODE_PACKAGE_JSON_FORBIDDEN' })); + + /** pluginRoot 的 Extension 追加另一个平台的 Manifest 路径。 */ + const pluginRoot = await temporaryProject(); + /** pluginExtension 不需要猜测 Plugin 内容,路径本身即越界。 */ + const pluginExtension = mcpContribution({ + id: 'plugin-injection', + value: {}, + asset: { path: '.cursor-plugin/plugin.json', bytes: '{}\n' }, + }); + /** pluginReport 必须拒绝把 Workspace 伪装成安装型 Plugin。 */ + const pluginReport = await run({ root: pluginRoot, command: 'validate', extensions: [pluginExtension], commit: false }); + expect(pluginReport.diagnostics).toContainEqual(expect.objectContaining({ code: 'OPENCODE_PLUGIN_MANIFEST_FORBIDDEN' })); + }); + + it('reports unsupported Runtime without compiling or generating fake assets', async () => { + /** Runtime import 若被编译必然失败,用于证明 capability 协商发生在 compile 前。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/runtime'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/runtime/cli.ts'), 'import "missing-runtime-package";\n'); + /** relaxed 接受已明确报告的 Runtime capability 差异。 */ + const report = await run({ root, platform: openCode({ strict: false }) }); + + expect(report.success, JSON.stringify(report.diagnostics, null, 2)).toBe(true); + expect(report.runtimes).toEqual([{ + id: 'cli', kind: 'executable', location: { path: 'src/runtime/cli.ts' }, built: false, + }]); + expect(report.compatibility).toContainEqual(expect.objectContaining({ + platform: 'opencode', subject: 'runtime:cli', capability: 'node20-esm', level: 'unsupported', + })); + expect(report.packages.flatMap(unit => unit.assets).some(asset => asset.path.startsWith('runtime/'))).toBe(false); + }); + + it('rejects unknown Platform and Component fields without config escape hatches', async () => { + expect(() => openCode({ workspace: { raw: true } } as never)).toThrow('Unknown OpenCode workspace option'); + expect(() => openCode({ config: {} } as never)).toThrow('Unknown OpenCode Platform option'); + /** root 的 namespace 包含未公开的 raw 字段。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/commands/invalid.md'), `--- +description: Invalid field. +platforms: + opencode: + raw: true +--- +Do not build. +`); + /** report 应保留 canonical namespace fieldPath。 */ + const report = await run({ root, command: 'validate', commit: false }); + expect(report.diagnostics).toContainEqual(expect.objectContaining({ + code: 'OPENCODE_COMPONENT_FIELD_UNKNOWN', fieldPath: ['platforms', 'opencode', 'raw'], + })); + }); +}); diff --git a/packages/platforms/opencode/tsconfig.json b/packages/platforms/opencode/tsconfig.json new file mode 100644 index 0000000..3ae4da2 --- /dev/null +++ b/packages/platforms/opencode/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/platforms/opencode/tsdown.config.ts b/packages/platforms/opencode/tsdown.config.ts new file mode 100644 index 0000000..4283c83 --- /dev/null +++ b/packages/platforms/opencode/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsdown'; + +/** OpenCode Platform 使用统一 Node 20 ESM 与声明输出。 */ +export default defineConfig({ + entry: ['./src/index.ts'], + format: ['esm'], + platform: 'node', + target: 'node20', + dts: { generator: 'oxc' }, + clean: true, + sourcemap: false, + deps: { neverBundle: ['@tokenroll/acplugin'] }, +}); diff --git a/packages/platforms/opencode/vitest.config.ts b/packages/platforms/opencode/vitest.config.ts new file mode 100644 index 0000000..b392d52 --- /dev/null +++ b/packages/platforms/opencode/vitest.config.ts @@ -0,0 +1,22 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +/** OpenCode 单测让公开主包与私有 Core 共享同一源码品牌实例。 */ +export default defineConfig({ + resolve: { + alias: [ + { + find: /^@tokenroll\/acplugin\/sdk$/, + replacement: fileURLToPath(new URL('../../acplugin/src/sdk.ts', import.meta.url)), + }, + { + find: /^@tokenroll\/acplugin$/, + replacement: fileURLToPath(new URL('../../acplugin/src/index.ts', import.meta.url)), + }, + { + find: /^@acplugin\/core$/, + replacement: fileURLToPath(new URL('../../core/src/index.ts', import.meta.url)), + }, + ], + }, +}); diff --git a/packages/platforms/pi/CHANGELOG.md b/packages/platforms/pi/CHANGELOG.md new file mode 100644 index 0000000..6f19585 --- /dev/null +++ b/packages/platforms/pi/CHANGELOG.md @@ -0,0 +1,26 @@ +# @tokenroll/acplugin-platform-pi + +## 0.0.3-beta + +### Major Changes + +- Add opaque, subject-bound Platform Component Contributions to the trusted Integration SDK. Core now transports strict JSON payloads and records scoped contributor provenance in BuildReport schema version 3 without acquiring Platform-specific Agent or target-format knowledge. + + Claude Code, Cursor, and OpenCode expose and render their own native Agent contribution payloads during Platform finalization. Codex, Antigravity, and Pi explicitly reject non-empty private component contributions rather than silently dropping them or generating fallback Skills. + + Harden `AssetService.fromBytes()` to accept only exact data-object inputs, exact generated-origin fields, and `string | Uint8Array` bytes so third-party Integrations cannot rely on accessor, hidden-field, or array-like coercion. + +### Patch Changes + +- Updated dependencies + - @tokenroll/acplugin@0.0.3-beta + +## 0.0.2-beta + +### Major Changes + +- 889da32: Rewrite the Pi Platform around the Package API, Core-owned npm Manifest codec, native Prompt/Skill delivery, Agent guidance Skills, add-only Hooks discovery, final candidate validation, and explicit unsupported MCP and Node Runtime compatibility. + +### Patch Changes + +- Updated peer dependency on `@tokenroll/acplugin` to `^0.0.2-beta`. diff --git a/packages/platforms/pi/LICENSE b/packages/platforms/pi/LICENSE new file mode 100644 index 0000000..9113de7 --- /dev/null +++ b/packages/platforms/pi/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 TokenRollAI + +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/packages/platforms/pi/README.md b/packages/platforms/pi/README.md new file mode 100644 index 0000000..58812d3 --- /dev/null +++ b/packages/platforms/pi/README.md @@ -0,0 +1,27 @@ +# @tokenroll/acplugin-platform-pi + +Pi Platform package for `@tokenroll/acplugin`. + +```bash +pnpm add -D @tokenroll/acplugin @tokenroll/acplugin-platform-pi +``` + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import pi from '@tokenroll/acplugin-platform-pi'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [pi()], +}); +``` + +The package also exports the named `pi` factory, its option types, `PLATFORM_ID`, and `PLATFORM_API_VERSION`. + +Pi does not currently expose a Platform Component Contribution payload. A non-empty private component contribution fails during Package finalization rather than becoming a guidance Skill. + +## License + +MIT diff --git a/packages/platforms/pi/package.json b/packages/platforms/pi/package.json new file mode 100644 index 0000000..15b747c --- /dev/null +++ b/packages/platforms/pi/package.json @@ -0,0 +1,29 @@ +{ + "name": "@tokenroll/acplugin-platform-pi", + "version": "0.0.3-beta", + "description": "Pi Platform integration for acplugin.", + "type": "module", + "license": "MIT", + "homepage": "https://github.com/TokenRollAI/acplugin#pi-platform", + "repository": { "type": "git", "url": "git+https://github.com/TokenRollAI/acplugin.git", "directory": "packages/platforms/pi" }, + "bugs": { "url": "https://github.com/TokenRollAI/acplugin/issues" }, + "sideEffects": false, + "engines": { "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, + "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" } }, + "files": ["dist", "README.md", "LICENSE"], + "publishConfig": { "access": "public" }, + "scripts": { + "build": "tsdown", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc -p tsconfig.json" + }, + "peerDependencies": { "@tokenroll/acplugin": "workspace:^" }, + "devDependencies": { + "@acplugin/core": "workspace:*", + "@tokenroll/acplugin": "workspace:^", + "@types/node": "catalog:", + "tsdown": "catalog:", + "@typescript/native": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/platforms/pi/src/index.ts b/packages/platforms/pi/src/index.ts new file mode 100644 index 0000000..866a047 --- /dev/null +++ b/packages/platforms/pi/src/index.ts @@ -0,0 +1,83 @@ +import { + definePlatform, + type AcpluginPlatform, + type JsonObject, +} from '@tokenroll/acplugin/sdk'; +import { + createPiComponents, + hasGeneratedSkills, + validateGeneratedSkillIds, + validatePiComponent, +} from './package/components.js'; +import { createPackageDocument, validatePlatformOptions } from './package/manifest.js'; +import type { PiPlatformOptions } from './types.js'; +import { validatePiPackage } from './package/validator.js'; + +export type { PiPackageOptions, PiPlatformOptions } from './types.js'; + +/** Pi Platform 的稳定开放 ID。 */ +export const PLATFORM_ID = 'pi' as const; + +/** Pi Platform 实现的 Core API 版本。 */ +export const PLATFORM_API_VERSION = '1' as const; + +/** 创建只通过 Package API 交付 npm Package 的 Pi Platform。 */ +export function pi(options: PiPlatformOptions = {}): AcpluginPlatform { + validatePlatformOptions(options); + /** strict 由 Core 解释,其余选项复制、深冻后进入 Platform Session。 */ + const { strict, ...platformOptions } = options; + return definePlatform({ + id: PLATFORM_ID, + apiVersion: PLATFORM_API_VERSION, + deliveryType: 'package', + ...(strict === undefined ? {} : { strict }), + options: platformOptions as unknown as JsonObject, + /** Pi 不声明 Node Runtime 能力,Core 会对每个 Runtime 报告 unsupported。 */ + createSession({ options: sessionOptions }) { + return { + validateComponent: validatePiComponent, + /** base Package 包含 Prompt/Skill Assets 和唯一 npm Manifest Document。 */ + async createPackage({ project, assets, diagnostics }) { + /** idsValid 防止 native/fallback Skill namespace 有歧义时签发 Assets。 */ + const idsValid = validateGeneratedSkillIds(project, diagnostics); + /** components 只在 namespace 完整时创建。 */ + const components = idsValid + ? await createPiComponents(project, assets) + : { assets: Object.freeze([]), compatibility: Object.freeze([]) }; + /** manifest 根据真实 canonical 资源声明 discovery roots。 */ + const manifest = createPackageDocument({ + metadata: project.metadata, + options: sessionOptions, + hasSkills: hasGeneratedSkills(project), + hasPrompts: project.commands.length > 0, + }); + return { + documents: [manifest.document], + assets: components.assets, + compatibility: components.compatibility, + metadata: manifest.metadata, + }; + }, + /** + * Pi 尚未定义 Platform Component 的原生 package representation。 + * + * 拒绝非空贡献使 Extension 不能误以为自己的私有资源已被交付,并保持 + * 既有 canonical Agent → Skill conversion 与 private contribution 相互独立。 + */ + finalizePackage: ({ package: mergedPackage, diagnostics }) => { + if (mergedPackage.components.length > 0) { + diagnostics.report({ + code: 'PI_COMPONENT_CONTRIBUTION_UNSUPPORTED', + severity: 'error', + message: 'Pi does not support Platform Component contributions.', + }); + } + return { id: 'package', type: 'package' }; + }, + validatePackage: validatePiPackage, + }; + }, + }); +} + +export default pi; diff --git a/packages/platforms/pi/src/package/components.ts b/packages/platforms/pi/src/package/components.ts new file mode 100644 index 0000000..679d2bf --- /dev/null +++ b/packages/platforms/pi/src/package/components.ts @@ -0,0 +1,193 @@ +import { + markdownWithFrontmatter, + type AssetService, + type CanonicalProject, + type CompatibilityInput, + type DiagnosticService, + type PackageAssetInput, + type PlatformComponentValidationContext, +} from '@tokenroll/acplugin/sdk'; + +/** Pi 当前不开放未经官方文档确认的 Component 专属字段。 */ +const COMPONENT_FIELDS = new Set(); + +/** 最终 Pi Skill 命名空间中的一个 canonical owner。 */ +interface GeneratedSkillIdentity { + readonly id: string; + readonly subject: string; +} + +/** Pi base Package 的 Component 转换结果。 */ +export interface PiComponentPackage { + readonly assets: readonly PackageAssetInput[]; + readonly compatibility: readonly CompatibilityInput[]; +} + +/** 校验 Pi Component namespace,不允许 raw Frontmatter 逃逸。 */ +export function validatePiComponent(context: PlatformComponentValidationContext): void { + /** fields 是 Scanner 已复制冻结的 Pi namespace。 */ + const fields = context.component.platforms.pi ?? {}; + for (const field of Object.keys(fields)) { + if (!COMPONENT_FIELDS.has(field)) { + context.diagnostics.report({ + code: 'PI_COMPONENT_FIELD_UNKNOWN', + severity: 'error', + message: `Unknown Pi ${context.component.kind} field "${field}".`, + fieldPath: ['platforms', 'pi', field], + }); + } + } +} + +/** @returns 全部 native/fallback Skill 的最终身份。 */ +function generatedSkillIdentities(project: CanonicalProject): readonly GeneratedSkillIdentity[] { + return Object.freeze([ + ...project.skills.map(skill => Object.freeze({ id: skill.id, subject: `skill:${skill.id}` })), + ...project.agents.map(agent => Object.freeze({ id: `agent-${agent.id}`, subject: `agent:${agent.id}` })), + ]); +} + +/** 在任何 Asset 签发前拒绝最终 Skill ID 的 exact、case 或 NFC 冲突。 */ +export function validateGeneratedSkillIds(project: CanonicalProject, diagnostics: DiagnosticService): boolean { + /** owners 使用最严格目标文件系统的 NFC/case-fold key。 */ + const owners = new Map(); + /** valid 让调用方在 namespace 有歧义时跳过全部 Component Asset。 */ + let valid = true; + for (const identity of generatedSkillIdentities(project)) { + /** 显式规范化固定未来可能扩展的身份边界。 */ + const key = identity.id.normalize('NFC').toLowerCase(); + /** owner 是先占用相同最终 ID 的 canonical 来源。 */ + const owner = owners.get(key); + if (owner !== undefined) { + valid = false; + diagnostics.report({ + code: 'PI_GENERATED_SKILL_ID_COLLISION', + severity: 'error', + message: `${owner.subject} and ${identity.subject} both generate Pi Skill ID "${identity.id}".`, + hint: 'Rename one canonical Component so every native and fallback Skill ID is unique.', + }); + } else { + owners.set(key, identity); + } + } + return valid; +} + +/** 把 canonical Commands、Skills 与 Agents 转换为 Pi Prompt/Skill 资源。 */ +export async function createPiComponents( + project: CanonicalProject, + assets: AssetService, +): Promise { + /** output 只包含 Platform bytes 和 Scanner 授权的 Skill auxiliary refs。 */ + const output: PackageAssetInput[] = []; + /** compatibility 精确描述每个 canonical Component 的交付语义。 */ + const compatibility: CompatibilityInput[] = []; + for (const command of project.commands) { + /** Pi Prompt Template 使用原生参数 token 和受控 Frontmatter。 */ + const asset = await assets.fromBytes({ + bytes: markdownWithFrontmatter({ + description: command.description, + ...(command.argumentHint === undefined ? {} : { 'argument-hint': command.argumentHint }), + }, command.body.replaceAll('{{arguments}}', '$ARGUMENTS')), + origin: { operation: 'component-command', subjects: [`command:${command.id}`] }, + }); + output.push(Object.freeze({ path: `prompts/${command.id}.md`, asset })); + compatibility.push(Object.freeze({ + subject: `command:${command.id}`, + capability: 'component', + level: 'transform', + transformation: `prompt-template:${command.id}`.toLowerCase(), + reason: 'Pi packages represent reusable slash prompts as Prompt Templates.', + })); + if (command.body.includes('{{arguments}}')) { + compatibility.push(Object.freeze({ + subject: `command:${command.id}`, + capability: 'arguments', + level: 'native', + reason: 'Pi Prompt Templates support the $ARGUMENTS placeholder.', + })); + } + if (command.argumentHint !== undefined) { + compatibility.push(Object.freeze({ + subject: `command:${command.id}`, + capability: 'argument-hint', + level: 'native', + reason: 'Pi Prompt Templates support argument-hint metadata.', + })); + } + } + for (const skill of project.skills) { + /** Skill 主文档使用 Pi 原生 Agent Skill 结构。 */ + const asset = await assets.fromBytes({ + bytes: markdownWithFrontmatter({ name: skill.id, description: skill.description }, skill.body), + origin: { operation: 'component-skill', subjects: [`skill:${skill.id}`] }, + }); + output.push(Object.freeze({ path: `skills/${skill.id}/SKILL.md`, asset })); + for (const auxiliary of skill.auxiliaryFiles) + output.push(Object.freeze({ path: `skills/${skill.id}/${auxiliary.path}`, asset: auxiliary.asset })); + compatibility.push(Object.freeze({ + subject: `skill:${skill.id}`, + capability: 'component', + level: 'native', + reason: 'Pi packages support Agent Skills natively.', + })); + if (!skill.invocation.user || !skill.invocation.model) { + compatibility.push(Object.freeze({ + subject: `skill:${skill.id}`, + capability: 'invocation', + level: 'degraded', + transformation: 'invocation-switches-omitted', + reason: 'Pi has no verified independent user and model invocation switches for Skills.', + })); + } + } + for (const agent of project.agents) { + /** Agent 使用固定前缀进入 Pi Skill namespace。 */ + const id = `agent-${agent.id}`; + /** guidance 明确保留但不谎报模型和 capability 强制能力。 */ + const guidance = [ + agent.body, + '', + `Intended model class: ${agent.model}.`, + `Intended capabilities: ${agent.capabilities.join(', ') || 'none declared'}.`, + 'Use this Skill as role guidance; Pi does not register it as a dedicated Agent.', + ].join('\n'); + /** fallback Skill 由 Pi Platform owner 签发。 */ + const asset = await assets.fromBytes({ + bytes: markdownWithFrontmatter({ name: id, description: agent.description }, guidance), + origin: { operation: 'component-agent', subjects: [`agent:${agent.id}`] }, + }); + output.push(Object.freeze({ path: `skills/${id}/SKILL.md`, asset })); + compatibility.push( + Object.freeze({ + subject: `agent:${agent.id}`, + capability: 'component', + level: 'degraded', + transformation: `guidance-skill:${id}`.toLowerCase(), + reason: 'Pi packages do not define a first-class static custom Agent resource.', + }), + Object.freeze({ + subject: `agent:${agent.id}`, + capability: 'agent.model', + level: 'degraded', + transformation: 'model-guidance', + reason: 'A fallback Skill cannot enforce an Agent model selection.', + }), + ); + if (agent.capabilities.length > 0) { + compatibility.push(Object.freeze({ + subject: `agent:${agent.id}`, + capability: 'agent.capabilities', + level: 'degraded', + transformation: 'capability-guidance', + reason: 'A fallback Skill cannot enforce an Agent capability boundary.', + })); + } + } + return Object.freeze({ assets: Object.freeze(output), compatibility: Object.freeze(compatibility) }); +} + +/** @returns Package 是否需要声明 Skills discovery root。 */ +export function hasGeneratedSkills(project: CanonicalProject): boolean { + return project.skills.length + project.agents.length > 0; +} diff --git a/packages/platforms/pi/src/package/manifest.ts b/packages/platforms/pi/src/package/manifest.ts new file mode 100644 index 0000000..729b981 --- /dev/null +++ b/packages/platforms/pi/src/package/manifest.ts @@ -0,0 +1,125 @@ +import type { + JsonObject, + MetadataDispositionInput, + PackageDocumentInput, + PluginMetadata, +} from '@tokenroll/acplugin/sdk'; +import type { PiPackageOptions, PiPlatformOptions } from '../types.js'; + +/** Pi npm package Manifest 的稳定 Document ID。 */ +export const PACKAGE_MANIFEST_ID = 'package-manifest'; + +/** Pi npm package Manifest 相对于交付根的固定路径。 */ +export const PACKAGE_MANIFEST_PATH = 'package.json'; + +/** @returns 值是否为非空字符串。 */ +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +/** 校验 Pi Platform 选项并拒绝任意 npm 字段透传。 */ +export function validatePlatformOptions(options: PiPlatformOptions): void { + /** Platform 顶层只允许 Core strict 和受控 package 子对象。 */ + const allowed = new Set(['strict', 'package']); + for (const field of Object.keys(options)) { + if (!allowed.has(field)) + throw new TypeError(`Unknown Pi Platform option "${field}".`); + } + if (options.strict !== undefined && typeof options.strict !== 'boolean') + throw new TypeError('Pi strict must be a boolean.'); + if (options.package === undefined) + return; + if (options.package === null || typeof options.package !== 'object' || Array.isArray(options.package)) + throw new TypeError('Pi package must be a plain object.'); + for (const field of Object.keys(options.package)) { + if (field !== 'image' && field !== 'video') + throw new TypeError(`Unknown Pi package option "${field}".`); + } + for (const field of ['image', 'video'] as const) { + if (options.package[field] !== undefined && !isNonEmptyString(options.package[field])) + throw new TypeError(`Pi package.${field} must be a non-empty string.`); + } +} + +/** @returns 当前工程实际 metadata 的完整 npm emitted/omitted disposition。 */ +function metadataDispositions(metadata: PluginMetadata): readonly MetadataDispositionInput[] { + /** outputs 保存字段和确定的 npm Manifest 位置。 */ + const outputs: [string, string | undefined][] = [ + ['name', `${PACKAGE_MANIFEST_PATH}.name`], + ['version', `${PACKAGE_MANIFEST_PATH}.version`], + ['description', `${PACKAGE_MANIFEST_PATH}.description`], + ]; + if (metadata.displayName !== undefined) + outputs.push(['displayName', undefined]); + if (metadata.author !== undefined) { + outputs.push(['author.name', `${PACKAGE_MANIFEST_PATH}.author.name`]); + if (metadata.author.email !== undefined) + outputs.push(['author.email', `${PACKAGE_MANIFEST_PATH}.author.email`]); + if (metadata.author.url !== undefined) + outputs.push(['author.url', `${PACKAGE_MANIFEST_PATH}.author.url`]); + } + for (const field of ['homepage', 'repository', 'license'] as const) { + if (metadata[field] !== undefined) + outputs.push([field, `${PACKAGE_MANIFEST_PATH}.${field}`]); + } + if (metadata.keywords.length > 0) + outputs.push(['keywords', `${PACKAGE_MANIFEST_PATH}.keywords`]); + return Object.freeze(outputs.map(([field, output]) => Object.freeze({ + field, + disposition: output === undefined ? 'omitted' as const : 'emitted' as const, + ...(output === undefined ? {} : { output }), + reason: output === undefined + ? 'The npm and Pi package contracts have no displayName field.' + : `npm package.json supports ${field}.`, + }))); +} + +/** 创建由 Core codec 序列化、只开放 Hooks discovery 点的 npm Manifest。 */ +export function createPackageDocument(input: { + readonly metadata: PluginMetadata; + readonly options: Readonly; + readonly hasSkills: boolean; + readonly hasPrompts: boolean; +}): { readonly document: PackageDocumentInput; readonly metadata: readonly MetadataDispositionInput[] } { + /** packageOptions 已由 factory 校验并由 Core 防御性复制。 */ + const packageOptions = input.options.package as PiPackageOptions | undefined; + /** pi-package keyword 与作者关键词保持首次出现顺序并稳定去重。 */ + const keywords = [...new Set([...input.metadata.keywords, 'pi-package'])]; + /** pi 只声明当前 Package 中真实存在或配置明确要求的 discovery 字段。 */ + const pi: JsonObject = { + ...(input.hasSkills ? { skills: ['./skills'] } : {}), + ...(input.hasPrompts ? { prompts: ['./prompts'] } : {}), + ...(packageOptions?.image === undefined ? {} : { image: packageOptions.image }), + ...(packageOptions?.video === undefined ? {} : { video: packageOptions.video }), + }; + /** Manifest 不继承消费 workspace 的 private/workspaces/dependencies。 */ + const value: JsonObject = { + name: input.metadata.name, + version: input.metadata.version, + description: input.metadata.description, + type: 'module', + keywords, + ...(input.metadata.author === undefined + ? {} + : { + author: { + name: input.metadata.author.name, + ...(input.metadata.author.email === undefined ? {} : { email: input.metadata.author.email }), + ...(input.metadata.author.url === undefined ? {} : { url: input.metadata.author.url }), + }, + }), + ...(input.metadata.homepage === undefined ? {} : { homepage: input.metadata.homepage }), + ...(input.metadata.repository === undefined ? {} : { repository: input.metadata.repository }), + ...(input.metadata.license === undefined ? {} : { license: input.metadata.license }), + pi, + }; + /** document 是 Platform 唯一拥有且不可被完整替换的 package.json。 */ + const document: PackageDocumentInput = Object.freeze({ + id: PACKAGE_MANIFEST_ID, + path: PACKAGE_MANIFEST_PATH, + format: 'json', + value, + extensionPoints: Object.freeze([Object.freeze(['pi', 'extensions'] as const)]), + }); + return Object.freeze({ document, metadata: metadataDispositions(input.metadata) }); +} diff --git a/packages/platforms/pi/src/package/validator.ts b/packages/platforms/pi/src/package/validator.ts new file mode 100644 index 0000000..27c47c6 --- /dev/null +++ b/packages/platforms/pi/src/package/validator.ts @@ -0,0 +1,147 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { JsonValue, ValidatePackageContext } from '@tokenroll/acplugin/sdk'; +import { PACKAGE_MANIFEST_PATH } from './manifest.js'; + +/** Pi validator 只消费 SDK 的最终 Package candidate Context。 */ +type PlatformValidateContext = ValidatePackageContext; + +/** ACPlugin 允许写入 Pi package.json 的固定根字段。 */ +const PACKAGE_FIELDS = new Set([ + 'name', 'version', 'description', 'type', 'author', 'homepage', 'repository', 'license', 'keywords', 'pi', +]); + +/** Pi discovery 对象允许的官方字段。 */ +const PI_FIELDS = new Set(['extensions', 'skills', 'prompts', 'themes', 'image', 'video']); + +/** JSON 对象的运行时只读索引类型。 */ +type JsonRecord = Record; + +/** @returns 未知值是否为非数组 JSON 对象。 */ +function isRecord(value: unknown): value is JsonRecord { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** 向 Core 提交不含宿主路径的 Pi candidate 错误。 */ +function report(context: PlatformValidateContext, code: string, message: string): void { + context.diagnostics.report({ code, severity: 'error', message }); +} + +/** @returns 安全 package-root POSIX 引用对应的 Asset path。 */ +function packageAssetPath(value: string): string | undefined { + if (!value.startsWith('./') || value.includes('\\') || value.includes('\0')) + return undefined; + /** relative 不允许空、dot、parent 或 glob segment。 */ + const relative = value.slice(2).replace(/\/+$/u, ''); + /** segments 用于精确拒绝目录逃逸和 ACPlugin 未生成的 glob 语义。 */ + const segments = relative.split('/'); + if (relative.length === 0 || segments.some(segment => segment === '' || segment === '.' || segment === '..' || /[*?[\]{}!]/u.test(segment))) + return undefined; + return relative; +} + +/** 校验一个 Pi discovery 数组安全且能在最终 Package 中找到资源。 */ +function validateDiscoveryPaths( + context: PlatformValidateContext, + assets: ReadonlySet, + field: 'extensions' | 'skills' | 'prompts' | 'themes', + value: JsonValue, +): void { + if (!Array.isArray(value) || value.length === 0 || value.some(item => typeof item !== 'string')) { + report(context, 'PI_DISCOVERY_PATH_INVALID', `pi.${field} must contain package-root path strings.`); + return; + } + for (const reference of value as readonly string[]) { + /** target 是引用指向的候选文件或目录根。 */ + const target = packageAssetPath(reference); + if (target === undefined) { + report(context, 'PI_DISCOVERY_PATH_INVALID', `pi.${field} contains an unsafe package-root path.`); + continue; + } + if (![...assets].some(asset => asset === target || asset.startsWith(`${target}/`))) + report(context, 'PI_DISCOVERY_PATH_MISSING', `pi.${field} references a missing package resource.`); + } +} + +/** 校验 Pi Gallery image/video 引用不含凭据且本地资源存在。 */ +function validateGalleryReference( + context: PlatformValidateContext, + assets: ReadonlySet, + field: 'image' | 'video', + value: JsonValue, +): void { + if (typeof value !== 'string' || value.trim().length === 0) { + report(context, 'PI_GALLERY_REFERENCE_INVALID', `pi.${field} must be a non-empty URL or package-root path.`); + return; + } + /** local 是 image 可使用的 package 内静态资源引用。 */ + const local = packageAssetPath(value); + if (local !== undefined) { + if (field === 'video' || !assets.has(local)) + report(context, 'PI_GALLERY_REFERENCE_INVALID', `pi.${field} does not reference a supported package resource.`); + return; + } + try { + /** 远程 Gallery 媒体只允许不带内联凭据的 HTTP(S) URL。 */ + const url = new URL(value); + if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username !== '' || url.password !== '') + throw new TypeError('Unsafe URL.'); + if (field === 'video' && !url.pathname.toLowerCase().endsWith('.mp4')) + throw new TypeError('Video must be MP4.'); + if (field === 'image' && !/\.(?:png|jpe?g|gif|webp)$/iu.test(url.pathname)) + throw new TypeError('Image format is unsupported.'); + } catch { + report(context, 'PI_GALLERY_REFERENCE_INVALID', `pi.${field} must use a supported HTTP(S) media URL without credentials.`); + } +} + +/** 校验 Pi npm Package 的 manifest、discovery closure 和 workspace 隔离。 */ +export async function validatePiPackage(context: PlatformValidateContext): Promise { + /** 当前候选 Package 的规范 Asset 路径集合。 */ + const assets = new Set(context.candidate.unit.assets.map(asset => asset.path)); + /** 从候选根加载且仍需严格校验的 npm Manifest。 */ + let manifest: JsonRecord; + try { + /** JSON.parse 返回 unknown,不能信任 Core codec 之外的候选物化结果。 */ + const value: unknown = JSON.parse(await fs.readFile(path.join(context.candidate.root, PACKAGE_MANIFEST_PATH), 'utf8')); + if (!isRecord(value)) + throw new TypeError('Manifest is not an object.'); + manifest = value; + } catch { + report(context, 'PI_PACKAGE_READ_FAILED', 'package.json must contain a JSON object.'); + return; + } + /** field 遍历用于拒绝 workspace/private/dependency 字段泄漏。 */ + for (const field of Object.keys(manifest)) { + if (!PACKAGE_FIELDS.has(field)) + report(context, 'PI_PACKAGE_FIELD_UNKNOWN', `Unknown generated Pi package field "${field}".`); + } + if (Object.hasOwn(manifest, 'private') || Object.hasOwn(manifest, 'workspaces')) + report(context, 'PI_PACKAGE_WORKSPACE_LEAK', 'Pi delivery package must not contain private or workspaces.'); + if (typeof manifest.name !== 'string' || manifest.name.trim().length === 0 + || typeof manifest.version !== 'string' || manifest.version.trim().length === 0 + || typeof manifest.description !== 'string' || manifest.description.trim().length === 0) { + report(context, 'PI_PACKAGE_METADATA_INVALID', 'Pi package requires non-empty name, version, and description.'); + } + if (manifest.type !== 'module') + report(context, 'PI_PACKAGE_MODULE_TYPE_INVALID', 'Pi package must declare type module.'); + if (!Array.isArray(manifest.keywords) || manifest.keywords.some(keyword => typeof keyword !== 'string') || !manifest.keywords.includes('pi-package')) + report(context, 'PI_PACKAGE_KEYWORD_MISSING', 'Pi package keywords must include pi-package.'); + if (!isRecord(manifest.pi)) { + report(context, 'PI_DISCOVERY_CONFIG_INVALID', 'package.json.pi must be an object.'); + return; + } + /** field 遍历拒绝任意 package loader 配置或未知执行入口。 */ + for (const field of Object.keys(manifest.pi)) { + if (!PI_FIELDS.has(field)) + report(context, 'PI_DISCOVERY_FIELD_UNKNOWN', `Unknown generated Pi discovery field "${field}".`); + } + for (const field of ['skills', 'prompts', 'extensions', 'themes'] as const) { + if (manifest.pi[field] !== undefined) + validateDiscoveryPaths(context, assets, field, manifest.pi[field]); + } + for (const field of ['image', 'video'] as const) { + if (manifest.pi[field] !== undefined) + validateGalleryReference(context, assets, field, manifest.pi[field]); + } +} diff --git a/packages/platforms/pi/src/types.ts b/packages/platforms/pi/src/types.ts new file mode 100644 index 0000000..b6222e2 --- /dev/null +++ b/packages/platforms/pi/src/types.ts @@ -0,0 +1,15 @@ +/** Pi npm package gallery 的受控展示选项。 */ +export interface PiPackageOptions { + /** 相对 package 根或远程 URL 的展示图片。 */ + readonly image?: string; + /** 远程演示视频 URL。 */ + readonly video?: string; +} + +/** 创建 Pi Platform 时可声明的公开选项。 */ +export interface PiPlatformOptions { + /** 覆盖当前 Platform 的兼容性严格度。 */ + readonly strict?: boolean; + /** 只影响 acplugin 生成的 Pi package manifest。 */ + readonly package?: PiPackageOptions; +} diff --git a/packages/platforms/pi/test/platform.test.ts b/packages/platforms/pi/test/platform.test.ts new file mode 100644 index 0000000..360f987 --- /dev/null +++ b/packages/platforms/pi/test/platform.test.ts @@ -0,0 +1,447 @@ +import { spawn } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + defineExtension, + resolveKernelConfig, + runKernelBuildSession, + type AcpluginExtension, + type BytesAssetRef, + type DiagnosticInput, + type JsonValue, + type PlatformContributor, + type ValidatePackageContext, +} from '@acplugin/core'; +import { pi } from '../src/index.js'; +import { PACKAGE_MANIFEST_PATH } from '../src/package/manifest.js'; +import { validatePiPackage } from '../src/package/validator.js'; + +/** 测试结束后统一删除的临时工程根目录。 */ +const temporaryRoots: string[] = []; + +/** 创建包含最小配置占位符且登记清理的工程。 */ +async function temporaryProject(): Promise { + /** root 是当前测试独占的临时工程。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-pi-platform-')); + temporaryRoots.push(root); + await fs.mkdir(path.join(root, 'src'), { recursive: true }); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), 'export default {}\n'); + return root; +} + +/** 写入 Prompt、Skill、Agent fallback、辅助文件和 Gallery 图片。 */ +async function writeCompleteProject(root: string): Promise { + await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/skills/review/references'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/agents'), { recursive: true }); + await fs.mkdir(path.join(root, 'public/assets'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/commands/release.md'), `--- +description: Prepare a release. +argumentHint: +--- +Prepare release {{arguments}}. +`); + await fs.writeFile(path.join(root, 'src/skills/review/SKILL.md'), '---\ndescription: Review a change.\n---\nReview the change.\n'); + await fs.writeFile(path.join(root, 'src/skills/review/references/checklist.md'), 'Review checklist.\n'); + await fs.writeFile(path.join(root, 'src/agents/reviewer.md'), `--- +description: Review code. +model: capable +capabilities: [filesystem:read, search] +--- +Review code. +`); + await fs.writeFile(path.join(root, 'public/assets/cover.png'), Buffer.from([137, 80, 78, 71])); +} + +/** 执行只包含 Pi 的真实 Kernel v2 BuildSession。 */ +async function run(input: { + readonly root: string; + readonly platform?: ReturnType; + readonly extensions?: readonly AcpluginExtension[]; + readonly command?: 'validate' | 'inspect' | 'build'; + readonly commit?: boolean; +}) { + /** command 决定生命周期语义,commit 只允许 build 使用。 */ + const command = input.command ?? 'build'; + /** resolved 与公开 Project API 使用相同的严格配置边界。 */ + const resolved = resolveKernelConfig({ + name: 'release-tools', + version: '1.2.3', + description: 'Release workflow tools.', + author: { name: 'TokenRoll', email: 'maintainers@example.com' }, + license: 'MIT', + keywords: ['release'], + platforms: [input.platform ?? pi()], + extensions: input.extensions ?? [], + }, { + projectRoot: input.root, + configFile: path.join(input.root, 'acplugin.config.ts'), + command, + mode: 'production', + }); + expect(resolved.diagnostics).toEqual([]); + return (await runKernelBuildSession({ + config: resolved.config!, + frameworkVersion: 'test', + commit: command === 'build' && (input.commit ?? true), + })).report; +} + +/** 不经过 Shell 插值运行 pnpm,用于真实 npm Package 消费验证。 */ +async function runPnpm(cwd: string, args: readonly string[]): Promise { + await new Promise((resolve, reject) => { + /** child 保留 stdout/stderr,失败时给出完整 pack/install 诊断。 */ + const child = spawn('pnpm', [...args], { cwd, stdio: ['ignore', 'pipe', 'pipe'] }); + /** stdout 保存真实包管理器输出。 */ + let stdout = ''; + /** stderr 保存真实包管理器错误。 */ + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + child.once('error', reject); + child.once('exit', (code) => { + if (code === 0) + resolve(); + else + reject(new Error(`pnpm ${args.join(' ')} failed (${code ?? 'signal'}):\n${stdout}${stderr}`)); + }); + }); +} + +/** 创建向 package.json.pi.extensions add-only 贡献一个 Pi Extension 的 Fixture。 */ +function hooksContribution(input: { + readonly id?: string; + readonly reference?: JsonValue; + readonly asset?: { readonly path: string; readonly bytes: string }; +} = {}): AcpluginExtension { + /** id 允许碰撞测试创建不同 Extension owner。 */ + const id = input.id ?? 'hooks-fixture'; + return defineExtension, Record, Record, { readonly asset?: BytesAssetRef }>({ + id, + apiVersion: '1', + options: {}, + resourceRoots: [], + /** 当前 Fixture 没有跨 Session 状态或依赖。 */ + createSession: () => ({ + /** 空对象表示 Fixture 本轮已发现。 */ + discover: () => ({}), + /** delivery tuple 必须由 Pi Contributor 完整覆盖。 */ + validate: (_context, state) => ({ + state, + subjects: [{ subject: `hook:${id}`, capabilities: ['delivery'] }], + }), + /** 可选 Extension JS 只通过 owner-scoped Asset Service 创建。 */ + async build({ assets }) { + if (input.asset === undefined) + return { state: {} }; + /** asset 将由同 owner Contributor add-only 追加。 */ + const asset = await assets.fromBytes({ + bytes: input.asset.bytes, + origin: { operation: 'pi-hooks-fixture', subjects: [`hook:${id}`] }, + }); + return { state: { asset } }; + }, + contributors: [{ + platform: 'pi', + platformApiVersion: '1', + /** Contributor 只填写精确声明点并追加自己的 Asset。 */ + contribute: (_context, built) => ({ + documentFields: [{ + document: 'package-manifest', + path: ['pi', 'extensions'], + value: input.reference ?? ['./extensions/acplugin-hooks.mjs'], + }], + ...(input.asset === undefined || built.asset === undefined + ? {} + : { assets: [{ path: input.asset.path, asset: built.asset }] }), + compatibility: [{ + subject: `hook:${id}`, + capability: 'delivery', + level: 'native', + reason: 'Pi loads the generated Hook bridge as a native package Extension.', + }], + }), + }], + }), + }); +} + +/** 创建没有 Pi Contributor 的 MCP Fixture,验证显式 unsupported。 */ +function unsupportedMcp(): AcpluginExtension { + return defineExtension({ + id: 'mcp-fixture', + apiVersion: '1', + resourceRoots: [], + /** 缺少 consumer 时 Core 不应调用 build。 */ + createSession: () => ({ + /** 空状态表示测试 MCP 已被发现。 */ + discover: () => ({}), + /** transport tuple 必须由 Core 为缺失 consumer 报告 unsupported。 */ + validate: (_context, state) => ({ + state, + subjects: [{ subject: 'mcp:docs', capabilities: ['transport'] }], + }), + /** 该回调被调用即表示 Core 错误构建了不受支持的 MCP。 */ + build: () => { + throw new Error('Pi must not build an unsupported MCP server.'); + }, + contributors: [], + }), + }); +} + +/** 创建 Pi 必须显式拒绝的非空私有 Component contribution。 */ +function unsupportedComponentContribution(): AcpluginExtension { + const contributor: PlatformContributor, { readonly kind: 'fixture-component' }> = { + platform: 'pi', + platformApiVersion: '1', + contribute: () => ({ + components: [{ subject: 'fixture:private-component', value: { kind: 'fixture-component' } }], + compatibility: [{ + subject: 'fixture:private-component', capability: 'delivery', level: 'native', + reason: 'The fixture requests private Component delivery.', + }], + }), + }; + return defineExtension({ + id: 'private-component-fixture', + apiVersion: '1', + resourceRoots: [], + createSession: () => ({ + discover: () => ({}), + validate: (_context, state) => ({ + state, subjects: [{ subject: 'fixture:private-component', capabilities: ['delivery'] }], + }), + build: (_context, state) => ({ state }), + contributors: [contributor], + }), + }); +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('Pi Platform Package API', () => { + it('packs, installs, and discovers native Prompts/Skills and Agent guidance from an independent consumer', async () => { + /** root 覆盖 Pi Package 的全部 canonical Component 和 Public 继承。 */ + const root = await temporaryProject(); + await writeCompleteProject(root); + /** relaxed 只接受已明确报告的 Agent fallback 降级。 */ + const report = await run({ root, platform: pi({ strict: false, package: { image: './assets/cover.png' } }) }); + /** output 是已通过 Pi candidate validator 的 npm Package 根。 */ + const output = path.join(root, 'dist/pi/package'); + /** consumer 与生成工程隔离,证明 npm pack 后资源仍可发现。 */ + const consumer = path.join(root, 'consumer'); + /** tarballs 避免 pack 输出污染受管 dist。 */ + const tarballs = path.join(root, 'tarballs'); + await fs.mkdir(consumer); + await fs.mkdir(tarballs); + await fs.writeFile(path.join(consumer, 'package.json'), '{"name":"pi-consumer","version":"1.0.0","private":true}\n'); + await runPnpm(output, ['pack', '--pack-destination', tarballs]); + /** tarball 是 pack 生成的唯一安装输入。 */ + const tarball = path.join(tarballs, (await fs.readdir(tarballs)).find(file => file.endsWith('.tgz'))!); + await runPnpm(consumer, ['add', '--ignore-scripts', tarball]); + /** installedRoot 模拟 Pi 从 node_modules 读取已安装 Package。 */ + const installedRoot = path.join(consumer, 'node_modules/release-tools'); + /** manifest 保留 ACPlugin 控制的最小 npm/Pi 发现契约。 */ + const manifest = JSON.parse(await fs.readFile(path.join(installedRoot, PACKAGE_MANIFEST_PATH), 'utf8')) as { + readonly private?: boolean; + readonly workspaces?: unknown; + readonly pi: { readonly skills: readonly string[]; readonly prompts: readonly string[]; readonly image: string }; + }; + + expect(report.success, JSON.stringify(report.diagnostics, null, 2)).toBe(true); + expect(manifest.private).toBeUndefined(); + expect(manifest.workspaces).toBeUndefined(); + expect(manifest.pi).toEqual({ image: './assets/cover.png', prompts: ['./prompts'], skills: ['./skills'] }); + await fs.access(path.join(installedRoot, 'prompts/release.md')); + await fs.access(path.join(installedRoot, 'skills/review/SKILL.md')); + await fs.access(path.join(installedRoot, 'skills/review/references/checklist.md')); + await fs.access(path.join(installedRoot, 'skills/agent-reviewer/SKILL.md')); + await fs.access(path.join(installedRoot, 'assets/cover.png')); + await expect(fs.readFile(path.join(installedRoot, 'prompts/release.md'), 'utf8')).resolves.toContain('$ARGUMENTS'); + expect(report.compatibility).toEqual(expect.arrayContaining([ + expect.objectContaining({ subject: 'command:release', capability: 'component', level: 'transform' }), + expect.objectContaining({ subject: 'skill:review', capability: 'component', level: 'native' }), + expect.objectContaining({ subject: 'agent:reviewer', capability: 'component', level: 'degraded' }), + ])); + expect(report.metadata).toContainEqual(expect.objectContaining({ field: 'author.email', disposition: 'emitted' })); + }, 30_000); + + it('accepts a Hooks add-only Contribution and rejects duplicate ownership of the same Document point', async () => { + /** validRoot 只验证一个原生 Pi Extension Asset 与声明。 */ + const validRoot = await temporaryProject(); + /** hooks 追加候选内实际存在的 JS 文件。 */ + const hooks = hooksContribution({ + asset: { path: 'extensions/acplugin-hooks.mjs', bytes: 'export default function setup() {}\n' }, + }); + /** valid 必须通过 Core merge 和最终 discovery closure 校验。 */ + const valid = await run({ root: validRoot, extensions: [hooks] }); + /** manifest 是 Core codec 合并后的最终 Document。 */ + const manifest = JSON.parse(await fs.readFile(path.join(validRoot, 'dist/pi/package/package.json'), 'utf8')); + expect(valid.success, JSON.stringify(valid.diagnostics, null, 2)).toBe(true); + expect(manifest.pi.extensions).toEqual(['./extensions/acplugin-hooks.mjs']); + expect(valid.packages[0]?.assets).toContainEqual(expect.objectContaining({ + path: 'extensions/acplugin-hooks.mjs', owner: 'extension:hooks-fixture', + })); + + /** collisionRoot 的两个无序 Contributor 占用相同精确 point。 */ + const collisionRoot = await temporaryProject(); + /** collision 与 Extension 配置顺序无关。 */ + const collision = await run({ + root: collisionRoot, + command: 'validate', + commit: false, + extensions: [hooksContribution({ id: 'first-hooks' }), hooksContribution({ id: 'second-hooks' })], + }); + expect(collision.success).toBe(false); + expect(collision.diagnostics).toContainEqual(expect.objectContaining({ + code: 'PLATFORM_CONTRIBUTION_FAILED', platform: 'pi', phase: 'contribute', + })); + }); + + it('reports unsupported MCP and Runtime without compiling or generating fake assets', async () => { + /** root 中不可解析 Runtime import 证明 capability 协商发生在 compile 前。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/runtime'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/runtime/cli.ts'), 'import "missing-runtime-package";\n'); + /** relaxed 接受两个显式 unsupported tuple。 */ + const report = await run({ root, platform: pi({ strict: false }), extensions: [unsupportedMcp()] }); + + expect(report.success, JSON.stringify(report.diagnostics, null, 2)).toBe(true); + expect(report.runtimes).toEqual([{ + id: 'cli', kind: 'executable', location: { path: 'src/runtime/cli.ts' }, built: false, + }]); + expect(report.compatibility).toEqual(expect.arrayContaining([ + expect.objectContaining({ platform: 'pi', subject: 'runtime:cli', capability: 'node20-esm', level: 'unsupported' }), + expect.objectContaining({ platform: 'pi', subject: 'mcp:docs', capability: 'transport', level: 'unsupported' }), + ])); + expect(report.packages[0]?.assets.some(asset => asset.path.startsWith('runtime/') || asset.path.startsWith('mcp/'))).toBe(false); + }); + + it('explicitly rejects non-empty private Component contributions', async () => { + const root = await temporaryProject(); + const report = await run({ + root, command: 'validate', commit: false, extensions: [unsupportedComponentContribution()], + }); + + expect(report.success).toBe(false); + expect(report.packages).toEqual([]); + expect(report.diagnostics).toContainEqual(expect.objectContaining({ + code: 'PI_COMPONENT_CONTRIBUTION_UNSUPPORTED', phase: 'finalize', platform: 'pi', + })); + }); + + it('rejects native/fallback Skill identity collisions and unknown Component fields before Package creation', async () => { + /** collisionRoot 的 native Skill 占用 Agent fallback 最终 ID。 */ + const collisionRoot = await temporaryProject(); + await fs.mkdir(path.join(collisionRoot, 'src/skills/agent-reviewer'), { recursive: true }); + await fs.mkdir(path.join(collisionRoot, 'src/agents'), { recursive: true }); + await fs.writeFile(path.join(collisionRoot, 'src/skills/agent-reviewer/SKILL.md'), '---\ndescription: Existing.\n---\nExisting.\n'); + await fs.writeFile(path.join(collisionRoot, 'src/agents/reviewer.md'), '---\ndescription: Reviewer.\n---\nReview.\n'); + /** collision 必须发生在有歧义的 Asset 签发前。 */ + const collision = await run({ root: collisionRoot, command: 'validate', commit: false }); + expect(collision.diagnostics).toContainEqual(expect.objectContaining({ + code: 'PI_GENERATED_SKILL_ID_COLLISION', platform: 'pi', phase: 'package', + })); + expect(collision.packages).toEqual([]); + + /** fieldRoot 验证 canonical Pi namespace 没有 raw escape hatch。 */ + const fieldRoot = await temporaryProject(); + await fs.mkdir(path.join(fieldRoot, 'src/commands'), { recursive: true }); + await fs.writeFile(path.join(fieldRoot, 'src/commands/invalid.md'), `--- +description: Invalid. +platforms: + pi: + raw: true +--- +Invalid. +`); + /** fieldReport 必须保留 canonical namespace 的准确 fieldPath。 */ + const fieldReport = await run({ root: fieldRoot, command: 'validate', commit: false }); + expect(fieldReport.diagnostics).toContainEqual(expect.objectContaining({ + code: 'PI_COMPONENT_FIELD_UNKNOWN', fieldPath: ['platforms', 'pi', 'raw'], + })); + }); + + it('rejects unsafe or missing Extension discovery references at the final candidate boundary', async () => { + /** root 的 Contribution 使用 parent escape 且不产生对应 Asset。 */ + const root = await temporaryProject(); + /** report 必须来自 Platform final candidate validator。 */ + const report = await run({ + root, + command: 'validate', + commit: false, + extensions: [hooksContribution({ reference: ['../escape.mjs'] })], + }); + + expect(report.success).toBe(false); + expect(report.diagnostics).toContainEqual(expect.objectContaining({ + code: 'PI_DISCOVERY_PATH_INVALID', platform: 'pi', phase: 'platform-validate', + })); + }); + + it('rejects malformed package candidates, workspace fields, unsafe Gallery URLs, and missing discovery assets', async () => { + /** root 是直接候选校验用的隔离 materialization 根。 */ + const root = await temporaryProject(); + await fs.writeFile(path.join(root, PACKAGE_MANIFEST_PATH), JSON.stringify({ + name: 'invalid-package', + version: '1.0.0', + description: 'Invalid Pi package.', + type: 'module', + keywords: ['pi-package'], + private: true, + pi: { + skills: ['./skills'], + image: 'https://user:secret@example.com/cover.png', + }, + })); + /** diagnostics 收集 Platform Validator 的稳定错误码。 */ + const diagnostics: DiagnosticInput[] = []; + /** context 只构造 validator 公开读取的 candidate snapshot 字段。 */ + const context = { + command: 'validate', + mode: 'production', + candidate: { + root, + unit: { + platform: 'pi', id: 'package', type: 'package', role: 'primary', + assets: [], compatibility: [], metadata: [], + }, + }, + diagnostics: { + /** report 只收集稳定结构化诊断,不读取候选外信息。 */ + report: (diagnostic: DiagnosticInput) => diagnostics.push(diagnostic), + }, + } as unknown as ValidatePackageContext; + await validatePiPackage(context); + + expect(diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'PI_PACKAGE_FIELD_UNKNOWN' }), + expect.objectContaining({ code: 'PI_PACKAGE_WORKSPACE_LEAK' }), + expect.objectContaining({ code: 'PI_DISCOVERY_PATH_MISSING' }), + expect.objectContaining({ code: 'PI_GALLERY_REFERENCE_INVALID' }), + ])); + }); + + it('validates and defensively copies factory options without raw npm escape hatches', () => { + expect(() => pi({ package: { dependencies: {} } } as never)).toThrow('Unknown Pi package option'); + expect(() => pi({ package: { video: '' } })).toThrow('package.video'); + /** input 在 factory 返回后继续可变,Platform options 必须保持原快照。 */ + const input = { package: { image: 'https://example.com/cover.png' } }; + /** platform 不能保留作者 input identity。 */ + const platform = pi(input); + input.package.image = 'https://example.com/mutated.png'; + expect(platform.options).toEqual({ package: { image: 'https://example.com/cover.png' } }); + expect(Object.isFrozen((platform.options as { package: object }).package)).toBe(true); + }); +}); diff --git a/packages/platforms/pi/tsconfig.json b/packages/platforms/pi/tsconfig.json new file mode 100644 index 0000000..3ae4da2 --- /dev/null +++ b/packages/platforms/pi/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/platforms/pi/tsdown.config.ts b/packages/platforms/pi/tsdown.config.ts new file mode 100644 index 0000000..199bf96 --- /dev/null +++ b/packages/platforms/pi/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsdown'; + +/** Pi Platform 使用统一 Node 20 ESM 与声明输出。 */ +export default defineConfig({ + entry: ['./src/index.ts'], + format: ['esm'], + platform: 'node', + target: 'node20', + dts: { generator: 'oxc' }, + clean: true, + sourcemap: false, + deps: { neverBundle: ['@tokenroll/acplugin'] }, +}); diff --git a/packages/platforms/pi/vitest.config.ts b/packages/platforms/pi/vitest.config.ts new file mode 100644 index 0000000..6b2152e --- /dev/null +++ b/packages/platforms/pi/vitest.config.ts @@ -0,0 +1,22 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +/** Pi 单测让公开 SDK 与私有 Core 共享同一源码品牌实例。 */ +export default defineConfig({ + resolve: { + alias: [ + { + find: /^@tokenroll\/acplugin\/sdk$/, + replacement: fileURLToPath(new URL('../../acplugin/src/sdk.ts', import.meta.url)), + }, + { + find: /^@acplugin\/core\/integration$/, + replacement: fileURLToPath(new URL('../../core/src/api/integration.ts', import.meta.url)), + }, + { + find: /^@acplugin\/core$/, + replacement: fileURLToPath(new URL('../../core/src/index.ts', import.meta.url)), + }, + ], + }, +}); diff --git a/packages/playground/README.md b/packages/playground/README.md new file mode 100644 index 0000000..3892b92 --- /dev/null +++ b/packages/playground/README.md @@ -0,0 +1,40 @@ +# @acplugin/playground + +这是一个不绑定具体产品领域的完整 ACPlugin capability template。它通过真实公开 package、Kernel v2 Resource Provider、Core Compiler、Platform Package、无序 Contributor、候选校验和托管事务构建六个平台产物。 + +```bash +pnpm --filter @acplugin/playground typecheck +pnpm --filter @acplugin/playground validate +pnpm --filter @acplugin/playground build +``` + +在干净 checkout 中请从仓库根运行 `pnpm playground:check`,它会先构建 CLI 与公开 packages。 + +## 包含的模板 + +- `init`、`update`、`prune`、`upgrade` 四个通用工程 Command;`init` 同时覆盖 argument hint、参数占位符和 Claude Code/Codex 平台字段。 +- `project-workflow` Skill、四个通用工作流 references 和两个 Codex Skill icon auxiliary files。 +- `investigator`、`reflector`、`recorder` 三个 Agent。 +- 全部 11 个 portable Hook 事件,覆盖 matcher、timeout、status message、Codex context limit 和事件级语义结果。 +- Public、OAuth scopes、Bearer env/header 三种远程 HTTP MCP,以及可真实执行 `initialize`、`tools/list`、`tools/call` 的 local stdio MCP。 +- 可在 Claude Code/Codex 交付并真实执行、在其余平台明确报告 unsupported 的 Node 20 ESM Runtime。 +- runtime/schema/upgrade 静态资源示例、品牌 SVG 和四个 Public Markdown 模板。 +- Claude Code/Codex 自包含 Marketplace,以及 Cursor、Antigravity、OpenCode、Pi 的主 Package。 + +## 验证范围 + +仓库根的 `pnpm playground:check` 不只检查文件是否存在。`scripts/verify-playground.mjs` 会: + +- 对六个平台的 210 条 compatibility 记录和 83 条预期降级/不支持项执行精确白名单校验,并核对 66 条 metadata disposition。 +- 比较 schema-v3 `BuildReport.packages[].assets` 与真实 `dist` 文件树,按字节检查 Skill auxiliary、Public、Runtime 和 Marketplace 继承内容。 +- 解析 Manifest、Hooks 配置和 MCP 配置,确认所有引用存在,且 unsupported 能力没有伪造 Asset。 +- 用真实子进程执行每个受支持的自包含 Hook `handler.mjs`、三个 local MCP bundle 和双平台 Node Runtime。 +- 用 Secret 探针扫描报告和产物,连续构建两次并比较全部文件 hash 与 mode。 + +## 模板边界 + +此工程只演示 ACPlugin 的作者资源、配置字段、平台转换和 Extension Contributor 协议。Command、Skill、Agent、Hook、MCP、Runtime 与 Public 文件使用无持久化副作用的示例逻辑,第三方作者应替换为自己的产品能力。 + +Codex、Antigravity 和 Pi 会把 Agent 降级成 `agent-*` guidance Skill;Codex 把 Command 转成 `-` Skill,Antigravity 转成 `command-*` Skill,Pi 转成 Prompt Template。配置使用 `strict: false` 以展示平台差异,但 verifier 只接受六平台矩阵中逐项声明的 degradation/unsupported。 + +本 workspace 是能力覆盖和 packaging smoke,不是任何具体产品的实现或平台官方 conformance suite。 diff --git a/packages/playground/acplugin.config.ts b/packages/playground/acplugin.config.ts new file mode 100644 index 0000000..6f5ca97 --- /dev/null +++ b/packages/playground/acplugin.config.ts @@ -0,0 +1,84 @@ +import { defineConfig } from '@tokenroll/acplugin'; +import hooks from '@tokenroll/acplugin-extension-hooks'; +import mcp from '@tokenroll/acplugin-extension-mcp'; +import antigravity from '@tokenroll/acplugin-platform-antigravity'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; +import cursor from '@tokenroll/acplugin-platform-cursor'; +import openCode from '@tokenroll/acplugin-platform-opencode'; +import pi from '@tokenroll/acplugin-platform-pi'; + +/** 覆盖全部官方集成能力的 ACPlugin 模板配置。 */ +export default defineConfig({ + name: 'acplugin-playground', + version: '0.1.0', + description: 'Complete ACPlugin capability template for integration exercises.', + displayName: 'ACPlugin Capability Playground', + author: { + name: 'TokenRoll', + email: 'maintainers@tokenroll.ai', + url: 'https://github.com/TokenRollAI', + }, + homepage: 'https://github.com/TokenRollAI/acplugin', + repository: 'https://github.com/TokenRollAI/acplugin', + license: 'MIT', + keywords: ['acplugin', 'plugin-template', 'agent-skills', 'mcp'], + platforms: [ + claudeCode({ + defaultEnabled: false, + marketplace: { + name: 'acplugin-capability-playground-marketplace', + owner: { name: 'TokenRoll', email: 'maintainers@tokenroll.ai' }, + category: 'Developer Tools', + tags: ['acplugin', 'plugin-template'], + }, + }), + codex({ + interface: { + shortDescription: 'Explore a complete ACPlugin authoring template.', + longDescription: 'A repository-local template that exercises canonical resources, Hooks, MCP, Public files, and all six official Platform deliveries.', + developerName: 'TokenRoll', + category: 'Developer Tools', + capabilities: ['Command workflows', 'Lifecycle hooks', 'MCP tools'], + websiteURL: 'https://github.com/TokenRollAI/acplugin', + supportURL: 'https://github.com/TokenRollAI/acplugin/issues', + defaultPrompt: [ + 'Initialize the ACPlugin capability template for this repository.', + 'Review the generated Platform outputs for this template.', + ], + brandColor: '#FACC15', + brandColorDark: '#EAB308', + composerIcon: './assets/acplugin.svg', + logo: './assets/acplugin.svg', + }, + marketplace: { + name: 'acplugin-capability-playground-marketplace', + displayName: 'ACPlugin Capability Playground Marketplace', + category: 'Developer Tools', + policy: { installation: 'AVAILABLE' }, + }, + }), + cursor({ + publisher: 'TokenRoll', + logo: './assets/acplugin.svg', + category: 'Developer Tools', + tags: ['acplugin', 'plugin-template'], + minClientVersions: { cursor: '1.0.0' }, + }), + antigravity(), + openCode({ workspace: { schema: true } }), + pi({ + package: { + image: './assets/acplugin.svg', + video: 'https://example.com/acplugin-playground.mp4', + }, + }), + ], + runtime: { + entries: { + playground: { entry: 'main.ts' }, + }, + }, + extensions: [hooks(), mcp()], + build: { strict: false }, +}); diff --git a/packages/playground/package.json b/packages/playground/package.json new file mode 100644 index 0000000..783aeb9 --- /dev/null +++ b/packages/playground/package.json @@ -0,0 +1,27 @@ +{ + "name": "@acplugin/playground", + "version": "0.0.1-beta", + "private": true, + "type": "module", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "scripts": { + "typecheck": "tsc -p tsconfig.json", + "validate": "acplugin validate", + "build": "acplugin build" + }, + "devDependencies": { + "@tokenroll/acplugin": "workspace:^", + "@tokenroll/acplugin-extension-hooks": "workspace:^", + "@tokenroll/acplugin-extension-mcp": "workspace:^", + "@tokenroll/acplugin-platform-antigravity": "workspace:^", + "@tokenroll/acplugin-platform-claude-code": "workspace:^", + "@tokenroll/acplugin-platform-codex": "workspace:^", + "@tokenroll/acplugin-platform-cursor": "workspace:^", + "@tokenroll/acplugin-platform-opencode": "workspace:^", + "@tokenroll/acplugin-platform-pi": "workspace:^", + "@types/node": "catalog:", + "@typescript/native": "catalog:" + } +} diff --git a/packages/playground/public/assets/acplugin.svg b/packages/playground/public/assets/acplugin.svg new file mode 100644 index 0000000..3fb816a --- /dev/null +++ b/packages/playground/public/assets/acplugin.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/playground/public/resources/templates/document.md b/packages/playground/public/resources/templates/document.md new file mode 100644 index 0000000..4e51a81 --- /dev/null +++ b/packages/playground/public/resources/templates/document.md @@ -0,0 +1,13 @@ +# Task plan template + +## Objective + +写出范围明确、可以验收的任务目标。 + +## Inputs + +列出源码入口、配置、接口和其他可信输入。 + +## Acceptance + +列出验证命令、预期输出和明确非目标。 diff --git a/packages/playground/public/resources/templates/domain.md b/packages/playground/public/resources/templates/domain.md new file mode 100644 index 0000000..933802a --- /dev/null +++ b/packages/playground/public/resources/templates/domain.md @@ -0,0 +1,13 @@ +# Component template + +## Scope + +描述此 Component 覆盖的代码、责任和明确排除项。 + +## Sources + +列出可复核源码入口和权威文档。 + +## Invariants + +记录必须持续成立的架构、数据和行为约束。 diff --git a/packages/playground/public/resources/templates/investigation.md b/packages/playground/public/resources/templates/investigation.md new file mode 100644 index 0000000..ac490c6 --- /dev/null +++ b/packages/playground/public/resources/templates/investigation.md @@ -0,0 +1,13 @@ +# Investigation template + +## Question + +记录要回答的问题和调查边界。 + +## Evidence + +逐项区分直接事实与推断。 + +## Open issues + +列出仍需用户选择或外部事实才能解决的项目。 diff --git a/packages/playground/public/resources/templates/reflection-case.md b/packages/playground/public/resources/templates/reflection-case.md new file mode 100644 index 0000000..3cc3ee2 --- /dev/null +++ b/packages/playground/public/resources/templates/reflection-case.md @@ -0,0 +1,13 @@ +# Review template + +## Finding + +描述可复现的问题、证据和影响范围。 + +## Counterexamples + +检查结论在哪些场景不成立或属于预期行为。 + +## Decision + +记录修复、接受、暂缓或拒绝及其理由。 diff --git a/packages/playground/public/runtime/README.md b/packages/playground/public/runtime/README.md new file mode 100644 index 0000000..1b8508a --- /dev/null +++ b/packages/playground/public/runtime/README.md @@ -0,0 +1,3 @@ +# Runtime asset example + +此目录展示 Public 资源可以按原始字节进入每个交付单元。它不包含可执行代码,第三方作者可以将其替换为目标插件需要的静态 runtime 资源。 diff --git a/packages/playground/public/schemas/README.md b/packages/playground/public/schemas/README.md new file mode 100644 index 0000000..1c9e827 --- /dev/null +++ b/packages/playground/public/schemas/README.md @@ -0,0 +1,3 @@ +# Schema asset example + +此目录展示 Schema 等静态 Public 文件的交付位置。能力模板不定义业务数据结构,第三方作者应提供与自身输入格式匹配的严格 Schema。 diff --git a/packages/playground/public/upgrade/README.md b/packages/playground/public/upgrade/README.md new file mode 100644 index 0000000..3d74ae6 --- /dev/null +++ b/packages/playground/public/upgrade/README.md @@ -0,0 +1,3 @@ +# Upgrade guide example + +此目录展示升级说明等静态 Public 文件。能力模板只提供 Command 工作流示例,不包含任何特定依赖或产品格式的升级实现。 diff --git a/packages/playground/src/agents/investigator.md b/packages/playground/src/agents/investigator.md new file mode 100644 index 0000000..e5c04af --- /dev/null +++ b/packages/playground/src/agents/investigator.md @@ -0,0 +1,9 @@ +--- +description: 调查源码和测试并形成可复核证据清单 +model: capable +capabilities: + - filesystem:read + - search +--- + +读取指定范围内的源码、配置与测试,逐条记录证据位置、事实、推断和未解决问题。不要修改文件;证据不足时明确停止并请求补充范围。 diff --git a/packages/playground/src/agents/recorder.md b/packages/playground/src/agents/recorder.md new file mode 100644 index 0000000..f8d1399 --- /dev/null +++ b/packages/playground/src/agents/recorder.md @@ -0,0 +1,9 @@ +--- +description: 把已确认结论整理成可实施的改动草案 +model: inherit +capabilities: + - filesystem:read + - filesystem:write +--- + +只根据已确认结论生成结构化改动草案,保留来源、适用范围和验证步骤。写入前必须再次校验目标文件、现有改动和潜在冲突。 diff --git a/packages/playground/src/agents/reflector.md b/packages/playground/src/agents/reflector.md new file mode 100644 index 0000000..8c5ffd9 --- /dev/null +++ b/packages/playground/src/agents/reflector.md @@ -0,0 +1,8 @@ +--- +description: 复核候选改动并识别冲突、遗漏和回归风险 +model: capable +capabilities: + - filesystem:read +--- + +比较需求、调查证据、候选改动和测试结果,输出可接受、需修订、应移除及仍不确定的项目。不要把未经验证的假设当成已实现行为。 diff --git a/packages/playground/src/commands/init.md b/packages/playground/src/commands/init.md new file mode 100644 index 0000000..48fe595 --- /dev/null +++ b/packages/playground/src/commands/init.md @@ -0,0 +1,26 @@ +--- +description: 初始化一个通用的 ACPlugin 能力模板 +argumentHint: +requires: + skills: + - project-workflow +platforms: + claude-code: + allowedTools: + - Read + - Glob + - Grep + model: sonnet + codex: + displayName: Initialize capability template + shortDescription: Plan a complete ACPlugin capability example. + brandColor: '#FACC15' + defaultPrompt: Initialize the ACPlugin capability template for the supplied target. + products: + - CHAT + - CODEX +--- + +为 `{{arguments}}` 规划一个包含 Command、Skill、Agent、Hook、MCP 与 Public 文件的 ACPlugin capability template,并说明各资源的职责。 + +当前 Playground 只展示作者工程结构和平台构建能力,不替目标工程实现业务逻辑。 diff --git a/packages/playground/src/commands/prune.md b/packages/playground/src/commands/prune.md new file mode 100644 index 0000000..e8765de --- /dev/null +++ b/packages/playground/src/commands/prune.md @@ -0,0 +1,10 @@ +--- +description: 审查并规划移除不再需要的工程资源 +requires: + skills: + - project-workflow + agents: + - reflector +--- + +使用 ACPlugin capability template 审查 `{{arguments}}` 中过期、重复或不再被引用的文件。输出保留、合并和删除建议及理由,不直接执行破坏性操作。 diff --git a/packages/playground/src/commands/update.md b/packages/playground/src/commands/update.md new file mode 100644 index 0000000..0b0c958 --- /dev/null +++ b/packages/playground/src/commands/update.md @@ -0,0 +1,10 @@ +--- +description: 根据需求规划并验证一次工程更新 +requires: + skills: + - project-workflow + agents: + - investigator +--- + +使用 ACPlugin capability template 调查 `{{arguments}}` 的源码、测试和约束,形成带证据的改动建议、风险与待确认项,并列出验证命令。 diff --git a/packages/playground/src/commands/upgrade.md b/packages/playground/src/commands/upgrade.md new file mode 100644 index 0000000..967cc83 --- /dev/null +++ b/packages/playground/src/commands/upgrade.md @@ -0,0 +1,10 @@ +--- +description: 规划一个带兼容性检查的版本升级 +requires: + skills: + - project-workflow + agents: + - recorder +--- + +使用 ACPlugin capability template 针对 `{{arguments}}` 列出版本差异、兼容性风险、回退方式和验收步骤。此 Command 只展示工作流,不包含特定依赖的升级实现。 diff --git a/packages/playground/src/hooks/permission-request/hook.ts b/packages/playground/src/hooks/permission-request/hook.ts new file mode 100644 index 0000000..edf1f39 --- /dev/null +++ b/packages/playground/src/hooks/permission-request/hook.ts @@ -0,0 +1,13 @@ +import type { Hook } from '@tokenroll/acplugin-extension-hooks'; + +/** 把权限决策交回宿主,证明 defer 语义可被打包而不自动授权。 */ +export default { + event: 'PermissionRequest', + /** 返回由宿主继续处理的权限决策。 */ + run() { + return { + decision: 'defer', + reason: 'The host remains responsible for user authorization.', + }; + }, +} satisfies Hook<'PermissionRequest'>; diff --git a/packages/playground/src/hooks/post-compact/hook.ts b/packages/playground/src/hooks/post-compact/hook.ts new file mode 100644 index 0000000..b99bc73 --- /dev/null +++ b/packages/playground/src/hooks/post-compact/hook.ts @@ -0,0 +1,13 @@ +import type { Hook } from '@tokenroll/acplugin-extension-hooks'; + +/** 展示压缩后的继续决策,不声称已恢复真实 runtime 状态。 */ +export default { + event: 'PostCompact', + /** 返回压缩完成后的继续决策。 */ + run() { + return { + decision: 'continue', + reason: 'The static template has no continuation state to restore.', + }; + }, +} satisfies Hook<'PostCompact'>; diff --git a/packages/playground/src/hooks/post-tool-use/hook.ts b/packages/playground/src/hooks/post-tool-use/hook.ts new file mode 100644 index 0000000..e2f6d2e --- /dev/null +++ b/packages/playground/src/hooks/post-tool-use/hook.ts @@ -0,0 +1,14 @@ +import type { Hook } from '@tokenroll/acplugin-extension-hooks'; + +/** 展示工具执行后的 pass 决策和非阻断上下文。 */ +export default { + event: 'PostToolUse', + /** 返回工具执行后的非阻断观察结果。 */ + run() { + return { + decision: 'pass', + reason: 'The playground only observes successful tool completion.', + additionalContext: 'Verify claims against canonical source and the generated delivery together.', + }; + }, +} satisfies Hook<'PostToolUse'>; diff --git a/packages/playground/src/hooks/pre-compact/hook.ts b/packages/playground/src/hooks/pre-compact/hook.ts new file mode 100644 index 0000000..463190f --- /dev/null +++ b/packages/playground/src/hooks/pre-compact/hook.ts @@ -0,0 +1,13 @@ +import type { Hook } from '@tokenroll/acplugin-extension-hooks'; + +/** 展示压缩前扩展点;playground 不保存续接状态或阻止压缩。 */ +export default { + event: 'PreCompact', + /** 明确允许压缩,也不产生虚假的续接状态。 */ + run() { + return { + decision: 'continue', + reason: 'The template has no runtime state that must be persisted before compaction.', + }; + }, +} satisfies Hook<'PreCompact'>; diff --git a/packages/playground/src/hooks/pre-tool-use/hook.ts b/packages/playground/src/hooks/pre-tool-use/hook.ts new file mode 100644 index 0000000..f7be376 --- /dev/null +++ b/packages/playground/src/hooks/pre-tool-use/hook.ts @@ -0,0 +1,24 @@ +import type { Hook } from '@tokenroll/acplugin-extension-hooks'; + +/** 覆盖 matcher、timeout、statusMessage 和 Codex 上下文上限等 Hook 配置字段。 */ +export default { + event: 'PreToolUse', + matcher: '^(Read|Glob|Grep|read|glob|grep)$', + timeout: 15, + statusMessage: 'Checking a read-only playground tool call.', + platforms: { + codex: { additionalContextLimit: 512 }, + cursor: { timeout: 10 }, + antigravity: { timeout: 10 }, + opencode: { timeout: 10 }, + pi: { timeout: 10 }, + }, + /** 只展示允许决策,不改写原始工具输入。 */ + run() { + return { + decision: 'allow', + reason: 'The configured matcher only selects read-oriented tools.', + additionalContext: 'Treat generated Platform output as build artifacts, not canonical author input.', + }; + }, +} satisfies Hook<'PreToolUse'>; diff --git a/packages/playground/src/hooks/session-end/hook.ts b/packages/playground/src/hooks/session-end/hook.ts new file mode 100644 index 0000000..0d5bbca --- /dev/null +++ b/packages/playground/src/hooks/session-end/hook.ts @@ -0,0 +1,10 @@ +import type { Hook } from '@tokenroll/acplugin-extension-hooks'; + +/** 展示会话结束 advisory,不执行写入或清理副作用。 */ +export default { + event: 'SessionEnd', + /** 返回无副作用的会话结束提示。 */ + run() { + return { systemMessage: 'ACPlugin playground session finished without persistent runtime state.' }; + }, +} satisfies Hook<'SessionEnd'>; diff --git a/packages/playground/src/hooks/session-start/hook.ts b/packages/playground/src/hooks/session-start/hook.ts new file mode 100644 index 0000000..962cef2 --- /dev/null +++ b/packages/playground/src/hooks/session-start/hook.ts @@ -0,0 +1,16 @@ +import type { Hook } from '@tokenroll/acplugin-extension-hooks'; + +/** 展示会话开始扩展点;只追加能力模板的边界说明。 */ +export default { + event: 'SessionStart', + statusMessage: 'Loading the ACPlugin playground boundary.', + /** 明确继续会话,并提供可移植的上下文和 advisory 结果。 */ + run() { + return { + decision: 'continue', + reason: 'The playground only contributes static template guidance.', + additionalContext: 'This project is an ACPlugin capability template; example handlers do not implement product-specific behavior.', + systemMessage: 'ACPlugin playground template loaded.', + }; + }, +} satisfies Hook<'SessionStart'>; diff --git a/packages/playground/src/hooks/stop/hook.ts b/packages/playground/src/hooks/stop/hook.ts new file mode 100644 index 0000000..ba4a93a --- /dev/null +++ b/packages/playground/src/hooks/stop/hook.ts @@ -0,0 +1,13 @@ +import type { Hook } from '@tokenroll/acplugin-extension-hooks'; + +/** 展示停止扩展点;Playground 不执行业务写入或延长会话。 */ +export default { + event: 'Stop', + /** 明确完成,不延长会话,也不执行业务写入。 */ + run() { + return { + decision: 'finish', + reason: 'The capability template has no product-specific work to commit.', + }; + }, +} satisfies Hook<'Stop'>; diff --git a/packages/playground/src/hooks/subagent-start/hook.ts b/packages/playground/src/hooks/subagent-start/hook.ts new file mode 100644 index 0000000..1eb91c5 --- /dev/null +++ b/packages/playground/src/hooks/subagent-start/hook.ts @@ -0,0 +1,13 @@ +import type { Hook } from '@tokenroll/acplugin-extension-hooks'; + +/** 展示子代理启动时的上下文补充。 */ +export default { + event: 'SubagentStart', + /** 返回子代理启动时的模板上下文。 */ + run() { + return { + additionalContext: 'Use the investigator, recorder, and reflector roles as template guidance only.', + systemMessage: 'ACPlugin playground subagent template activated.', + }; + }, +} satisfies Hook<'SubagentStart'>; diff --git a/packages/playground/src/hooks/subagent-stop/hook.ts b/packages/playground/src/hooks/subagent-stop/hook.ts new file mode 100644 index 0000000..c0592ec --- /dev/null +++ b/packages/playground/src/hooks/subagent-stop/hook.ts @@ -0,0 +1,13 @@ +import type { Hook } from '@tokenroll/acplugin-extension-hooks'; + +/** 展示子代理结束时的 finish 决策。 */ +export default { + event: 'SubagentStop', + /** 返回子代理结束时的完成决策。 */ + run() { + return { + decision: 'finish', + reason: 'No additional playground-only work is required.', + }; + }, +} satisfies Hook<'SubagentStop'>; diff --git a/packages/playground/src/hooks/user-prompt-submit/hook.ts b/packages/playground/src/hooks/user-prompt-submit/hook.ts new file mode 100644 index 0000000..625b48a --- /dev/null +++ b/packages/playground/src/hooks/user-prompt-submit/hook.ts @@ -0,0 +1,14 @@ +import type { Hook } from '@tokenroll/acplugin-extension-hooks'; + +/** 展示用户提示提交前的允许决策和上下文补充。 */ +export default { + event: 'UserPromptSubmit', + /** 返回允许用户提示继续处理的决策。 */ + run() { + return { + decision: 'allow', + reason: 'The playground does not restrict author prompts.', + additionalContext: 'Keep conclusions tied to files that exist in this template repository.', + }; + }, +} satisfies Hook<'UserPromptSubmit'>; diff --git a/packages/playground/src/mcp/local-tools/mcp.ts b/packages/playground/src/mcp/local-tools/mcp.ts new file mode 100644 index 0000000..5436133 --- /dev/null +++ b/packages/playground/src/mcp/local-tools/mcp.ts @@ -0,0 +1,11 @@ +import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; + +/** 可被真实 initialize、tools/list 和 tools/call 探测的本地 stdio MCP 模板。 */ +export default { + transport: 'stdio', + entry: 'server.ts', + env: { + PLAYGROUND_MODE: { value: 'template' }, + PLAYGROUND_TOKEN: { env: 'PLAYGROUND_LOCAL_TOKEN' }, + }, +} satisfies McpServer; diff --git a/packages/playground/src/mcp/local-tools/server.ts b/packages/playground/src/mcp/local-tools/server.ts new file mode 100644 index 0000000..8445b2d --- /dev/null +++ b/packages/playground/src/mcp/local-tools/server.ts @@ -0,0 +1,92 @@ +/** JSON-RPC 请求 ID 的可移植表示。 */ +type RequestId = string | number | null; + +/** Playground MCP 只读取 smoke 和演示调用所需的请求字段。 */ +interface JsonRpcRequest { + readonly id?: RequestId; + readonly method?: string; + readonly params?: unknown; +} + +/** 向 stdout 写入一行确定性的 JSON-RPC 消息。 */ +function respond(id: RequestId, result: unknown): void { + process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`); +} + +/** 向 stdout 写入不包含输入或环境值的稳定 JSON-RPC 错误。 */ +function reject(id: RequestId, code: number, message: string): void { + process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } })}\n`); +} + +/** 判断未知值是否为可安全索引的普通对象。 */ +function isRecord(value: unknown): value is Readonly> { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** 处理一条完整 JSON-RPC 请求;通知不产生响应。 */ +function handle(request: JsonRpcRequest): void { + if (request.method === 'notifications/initialized') + return; + if (request.id === undefined) + return; + if (request.method === 'initialize') { + /** 客户端请求的协议版本;缺失时使用模板固定版本。 */ + const protocolVersion = isRecord(request.params) && typeof request.params.protocolVersion === 'string' + ? request.params.protocolVersion + : '2025-11-25'; + respond(request.id, { + protocolVersion, + capabilities: { tools: {} }, + serverInfo: { name: 'acplugin-playground', version: '0.1.0' }, + }); + return; + } + if (request.method === 'tools/list') { + respond(request.id, { + tools: [{ + name: 'inspect-template', + description: 'Describe the static ACPlugin playground boundary.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }], + }); + return; + } + if (request.method === 'tools/call') { + /** tools/call 的工具名必须来自 params.name。 */ + const name = isRecord(request.params) ? request.params.name : undefined; + if (name !== 'inspect-template') { + reject(request.id, -32_602, 'Unknown playground tool.'); + return; + } + respond(request.id, { + content: [{ + type: 'text', + text: 'This is a static ACPlugin capability template; product-specific behavior is intentionally absent.', + }], + isError: false, + }); + return; + } + reject(request.id, -32_601, 'Method not found.'); +} + +/** 当前尚未形成完整换行分帧的 stdin 文本。 */ +let inputBuffer = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk: string) => { + inputBuffer += chunk; + /** 本次数据后形成的完整行与末尾半行。 */ + const lines = inputBuffer.split('\n'); + inputBuffer = lines.pop() ?? ''; + for (const line of lines) { + if (line.trim() === '') + continue; + /** JSON.parse 结果只读取 JsonRpcRequest 的受控字段。 */ + const request = JSON.parse(line) as JsonRpcRequest; + handle(request); + } +}); diff --git a/packages/playground/src/mcp/oauth-docs/mcp.ts b/packages/playground/src/mcp/oauth-docs/mcp.ts new file mode 100644 index 0000000..a2f753f --- /dev/null +++ b/packages/playground/src/mcp/oauth-docs/mcp.ts @@ -0,0 +1,11 @@ +import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; + +/** 使用 OAuth scope 的远程 Streamable HTTP MCP 模板。 */ +export default { + transport: 'http', + url: 'https://mcp.example.com/oauth-docs', + auth: { + type: 'oauth', + scopes: ['resources:read', 'templates:read'], + }, +} satisfies McpServer; diff --git a/packages/playground/src/mcp/protected-docs/mcp.ts b/packages/playground/src/mcp/protected-docs/mcp.ts new file mode 100644 index 0000000..8b0854a --- /dev/null +++ b/packages/playground/src/mcp/protected-docs/mcp.ts @@ -0,0 +1,12 @@ +import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; + +/** 同时展示 Bearer、环境 Header 和公开字面量 Header 的远程 MCP 模板。 */ +export default { + transport: 'http', + url: 'https://mcp.example.com/protected-docs', + auth: { type: 'bearer', env: 'PLAYGROUND_MCP_TOKEN' }, + headers: { + 'X-Project': { value: 'acplugin-playground' }, + 'X-Tenant': { env: 'PLAYGROUND_MCP_TENANT' }, + }, +} satisfies McpServer; diff --git a/packages/playground/src/mcp/public-docs/mcp.ts b/packages/playground/src/mcp/public-docs/mcp.ts new file mode 100644 index 0000000..77cedb3 --- /dev/null +++ b/packages/playground/src/mcp/public-docs/mcp.ts @@ -0,0 +1,8 @@ +import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; + +/** 无认证的远程 Streamable HTTP MCP 模板。 */ +export default { + transport: 'http', + url: 'https://mcp.example.com/public-docs', + auth: { type: 'none' }, +} satisfies McpServer; diff --git a/packages/playground/src/runtime/main.ts b/packages/playground/src/runtime/main.ts new file mode 100644 index 0000000..b62b149 --- /dev/null +++ b/packages/playground/src/runtime/main.ts @@ -0,0 +1,2 @@ +/** Playground Runtime 只输出稳定的公开健康状态。 */ +process.stdout.write(`${JSON.stringify({ framework: 'acplugin', status: 'ready' })}\n`); diff --git a/packages/playground/src/skills/project-workflow/SKILL.md b/packages/playground/src/skills/project-workflow/SKILL.md new file mode 100644 index 0000000..28e6c2e --- /dev/null +++ b/packages/playground/src/skills/project-workflow/SKILL.md @@ -0,0 +1,44 @@ +--- +description: 调查、实施和复核通用工程任务 +invocation: + user: true + model: true +platforms: + claude-code: + allowedTools: + - Read + - Glob + - Grep + context: fork + agent: Explore + codex: + displayName: Project workflow + shortDescription: Plan, implement, and verify scoped project changes. + iconSmall: ./assets/icon-small.svg + iconLarge: ./assets/icon-large.svg + brandColor: '#FACC15' + defaultPrompt: Inspect the repository and propose a verified implementation plan. + products: + - CHAT + - CODEX +--- + +# Project workflow capability template + +在需要调查工程、规划改动、复核实现或准备交付时使用此 Skill。 + +## 工作方式 + +1. 确认任务范围、约束和预期输出。 +2. 调查源码与测试并记录可复核证据,不把推测写成事实。 +3. 实施最小改动,保留用户已有工作并明确风险。 +4. 运行与风险匹配的验证,并给出可复现的交付说明。 + +## References + +- [Planning](references/planning.md) +- [Verification](references/verification.md) +- [Review](references/review.md) +- [Context continuation](references/context-continuation.md) + +这些 references 是通用工作流示例,第三方作者可以替换为自己的领域说明和辅助资源。 diff --git a/packages/playground/src/skills/project-workflow/assets/icon-large.svg b/packages/playground/src/skills/project-workflow/assets/icon-large.svg new file mode 100644 index 0000000..8e64d63 --- /dev/null +++ b/packages/playground/src/skills/project-workflow/assets/icon-large.svg @@ -0,0 +1,5 @@ + + ACPlugin capability template + + + diff --git a/packages/playground/src/skills/project-workflow/assets/icon-small.svg b/packages/playground/src/skills/project-workflow/assets/icon-small.svg new file mode 100644 index 0000000..d75cd6e --- /dev/null +++ b/packages/playground/src/skills/project-workflow/assets/icon-small.svg @@ -0,0 +1,5 @@ + + ACPlugin capability template + + + diff --git a/packages/playground/src/skills/project-workflow/references/context-continuation.md b/packages/playground/src/skills/project-workflow/references/context-continuation.md new file mode 100644 index 0000000..eefe636 --- /dev/null +++ b/packages/playground/src/skills/project-workflow/references/context-continuation.md @@ -0,0 +1,5 @@ +# Context continuation + +长任务应保留最小续接信息:当前目标、已完成改动、验证结果、仍待处理步骤和不可违反的约束。 + +本模板的 `PreCompact` 与 `PostCompact` Hook 只展示协议结果,不持久化状态;真实 Extension 可以按产品需求实现自己的安全续接机制。 diff --git a/packages/playground/src/skills/project-workflow/references/planning.md b/packages/playground/src/skills/project-workflow/references/planning.md new file mode 100644 index 0000000..cfb49f7 --- /dev/null +++ b/packages/playground/src/skills/project-workflow/references/planning.md @@ -0,0 +1,5 @@ +# Planning + +开始实现前,列出目标、明确排除项、受影响 package、输入来源和可验收结果。 + +当需求仍有歧义时,区分能够安全假设的细节和会改变架构方向的用户决策。计划应保持可分批执行,每一步都有独立验证方式。 diff --git a/packages/playground/src/skills/project-workflow/references/review.md b/packages/playground/src/skills/project-workflow/references/review.md new file mode 100644 index 0000000..2f77b74 --- /dev/null +++ b/packages/playground/src/skills/project-workflow/references/review.md @@ -0,0 +1,5 @@ +# Review + +复核候选改动是否满足需求、是否破坏既有不变量,以及测试是否真正覆盖失败路径而非只覆盖文件存在性。 + +明确区分必须修复的问题、可选改进和风格偏好;每条结论都应指向可复核的代码或测试证据。 diff --git a/packages/playground/src/skills/project-workflow/references/verification.md b/packages/playground/src/skills/project-workflow/references/verification.md new file mode 100644 index 0000000..e3d7ceb --- /dev/null +++ b/packages/playground/src/skills/project-workflow/references/verification.md @@ -0,0 +1,5 @@ +# Verification + +验证应覆盖类型、静态规则、单元测试、集成边界和真实生成产物,并根据改动风险选择必要集合。 + +不要只检查命令退出码:同时检查报告、文件内容、权限、引用闭包和不支持能力是否没有生成伪产物。 diff --git a/packages/playground/tsconfig.json b/packages/playground/tsconfig.json new file mode 100644 index 0000000..e160c82 --- /dev/null +++ b/packages/playground/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["acplugin.config.ts", "src/**/*.ts"] +} diff --git a/test-fixture/.claude/agents/code-reviewer.md b/packages/test/fixtures/migration/claude-project/.claude/agents/code-reviewer.md similarity index 100% rename from test-fixture/.claude/agents/code-reviewer.md rename to packages/test/fixtures/migration/claude-project/.claude/agents/code-reviewer.md diff --git a/test-fixture/.claude/commands/deploy.md b/packages/test/fixtures/migration/claude-project/.claude/commands/deploy.md similarity index 100% rename from test-fixture/.claude/commands/deploy.md rename to packages/test/fixtures/migration/claude-project/.claude/commands/deploy.md diff --git a/test-fixture/.claude/rules/testing.md b/packages/test/fixtures/migration/claude-project/.claude/rules/testing.md similarity index 100% rename from test-fixture/.claude/rules/testing.md rename to packages/test/fixtures/migration/claude-project/.claude/rules/testing.md diff --git a/test-fixture/.claude/settings.json b/packages/test/fixtures/migration/claude-project/.claude/settings.json similarity index 100% rename from test-fixture/.claude/settings.json rename to packages/test/fixtures/migration/claude-project/.claude/settings.json diff --git a/test-fixture/.claude/skills/my-skill/SKILL.md b/packages/test/fixtures/migration/claude-project/.claude/skills/my-skill/SKILL.md similarity index 100% rename from test-fixture/.claude/skills/my-skill/SKILL.md rename to packages/test/fixtures/migration/claude-project/.claude/skills/my-skill/SKILL.md diff --git a/test-fixture/.mcp.json b/packages/test/fixtures/migration/claude-project/.mcp.json similarity index 100% rename from test-fixture/.mcp.json rename to packages/test/fixtures/migration/claude-project/.mcp.json diff --git a/test-fixture/CLAUDE.md b/packages/test/fixtures/migration/claude-project/CLAUDE.md similarity index 100% rename from test-fixture/CLAUDE.md rename to packages/test/fixtures/migration/claude-project/CLAUDE.md diff --git a/packages/test/package.json b/packages/test/package.json new file mode 100644 index 0000000..5e7875d --- /dev/null +++ b/packages/test/package.json @@ -0,0 +1,30 @@ +{ + "name": "@acplugin/test", + "version": "0.0.1-beta", + "private": true, + "type": "module", + "engines": { "node": ">=20" }, + "scripts": { + "pretest": "pnpm --filter @acplugin/core run build && pnpm --filter \"@tokenroll/acplugin-platform-*\" run build && pnpm --filter \"@tokenroll/acplugin-extension-*\" run build && pnpm --filter @tokenroll/acplugin run build", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { + "@acplugin/core": "workspace:*", + "@tokenroll/acplugin": "workspace:*", + "@tokenroll/acplugin-platform-antigravity": "workspace:*", + "@tokenroll/acplugin-platform-claude-code": "workspace:*", + "@tokenroll/acplugin-platform-codex": "workspace:*", + "@tokenroll/acplugin-platform-cursor": "workspace:*", + "@tokenroll/acplugin-platform-opencode": "workspace:*", + "@tokenroll/acplugin-platform-pi": "workspace:*", + "@tokenroll/acplugin-extension-hooks": "workspace:*", + "@tokenroll/acplugin-extension-mcp": "workspace:*" + }, + "devDependencies": { + "@types/node": "catalog:", + "@typescript/native": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/test/test/api/extension-api.types.ts b/packages/test/test/api/extension-api.types.ts new file mode 100644 index 0000000..5ad71d7 --- /dev/null +++ b/packages/test/test/api/extension-api.types.ts @@ -0,0 +1,32 @@ +import hooks from '@tokenroll/acplugin-extension-hooks'; +import mcp from '@tokenroll/acplugin-extension-mcp'; +import { + defineConfig, + nodeRuntimeArtifactPath, +} from '@tokenroll/acplugin'; +import type { AcpluginExtension } from '@tokenroll/acplugin/sdk'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; + +/** + * 验证两个公开 Extension 与 Core Runtime 配置只依赖主包正式生态类型。 + */ +export function verifyExtensionDeclarationTypes(): void { + /** Hooks 工厂返回的品牌化公开 Extension。 */ + const hooksExtension: AcpluginExtension = hooks({ include: ['format'] }); + /** MCP 工厂返回的品牌化公开 Extension。 */ + const mcpExtension: AcpluginExtension = mcp({ include: ['docs'] }); + /** 消费者显式安装 Platform、两个 Extension 并声明 Runtime 时能够解析的最终配置。 */ + const config = defineConfig({ + name: 'extension-declaration-consumer', + version: '1.0.0', + description: 'Verify public Extension declarations.', + platforms: [claudeCode()], + runtime: { entries: { cli: { entry: './cli.ts' } } }, + extensions: [hooksExtension, mcpExtension], + }); + + /** 公开 helper 的返回类型应保留固定 Runtime 路径形状。 */ + const runtimePath: `runtime/${string}/main.mjs` = nodeRuntimeArtifactPath('cli'); + void config; + void runtimePath; +} diff --git a/packages/test/test/api/legacy-api.types.ts b/packages/test/test/api/legacy-api.types.ts new file mode 100644 index 0000000..858d264 --- /dev/null +++ b/packages/test/test/api/legacy-api.types.ts @@ -0,0 +1,10 @@ +// @ts-expect-error 1.0 主入口不再公开旧 Module、Target 或 Compiler 生态类型。 +import type { AcpluginModule, TargetId } from '@tokenroll/acplugin'; + +/** + * 仅用于让 TypeScript 保留上方负向公开 API 断言。 + */ +export function verifyLegacyApiIsPrivate(): void { + void (undefined as unknown as AcpluginModule); + void (undefined as unknown as TargetId); +} diff --git a/packages/test/test/api/public-api.types.ts b/packages/test/test/api/public-api.types.ts new file mode 100644 index 0000000..95021a8 --- /dev/null +++ b/packages/test/test/api/public-api.types.ts @@ -0,0 +1,62 @@ +import { + defineConfig, +} from '@tokenroll/acplugin'; +import { + definePlatform, + type AcpluginPlatform, +} from '@tokenroll/acplugin/sdk'; +import antigravity from '@tokenroll/acplugin-platform-antigravity'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex, { PLATFORM_ID as CODEX_PLATFORM_ID } from '@tokenroll/acplugin-platform-codex'; +import cursor from '@tokenroll/acplugin-platform-cursor'; +import openCode from '@tokenroll/acplugin-platform-opencode'; +import pi from '@tokenroll/acplugin-platform-pi'; + +/** + * 验证主包只提供开放 SDK,官方 Platform 则通过独立 package 参与同一品牌契约。 + */ +export function verifyPublicPlatformTypes(): void { + /** 第三方平台通过主包公开工厂获得开放品牌。 */ + const community: AcpluginPlatform = definePlatform({ + id: 'community-platform', + apiVersion: '1', + deliveryType: 'plugin', + strict: true, + /** 为每轮构建创建最小隔离 Session。 */ + createSession: () => ({ + /** 返回空 Platform base Package。 */ + createPackage: () => ({ documents: [], assets: [], compatibility: [], metadata: [] }), + /** 固定主 Plugin Package 身份。 */ + finalizePackage: () => ({ id: 'plugin', type: 'plugin' }), + /** 不增加候选校验约束。 */ + validatePackage: () => undefined, + }), + }); + /** 六个官方 package 各自约束自己的工厂选项。 */ + const official = [ + claudeCode({ defaultEnabled: false, marketplace: { owner: { name: 'TokenRoll' } } }), + codex({ interface: { category: 'Productivity' } }), + cursor({ strict: false }), + antigravity({ strict: false }), + openCode({ workspace: { schema: true } }), + pi({ package: { image: './assets/cover.png' } }), + ]; + /** 最终配置使用显式 Platform/Extension 数组且保持顶层 metadata。 */ + const config = defineConfig({ + name: 'typed-config', + version: '1.0.0', + description: 'Typed config.', + platforms: official, + extensions: [], + }); + /** 独立 Codex package 暴露稳定 Platform ID。 */ + const codexId: 'codex' = CODEX_PLATFORM_ID; + // @ts-expect-error Antigravity 不接受属于 Marketplace Platform 的配置字段。 + antigravity({ marketplace: {} }); + // @ts-expect-error Cursor 1.0 没有经过验证的 Marketplace Distribution 配置。 + cursor({ marketplace: {} }); + /** defineConfig 保留开放字面量,旧字段不会进入 UserConfig 的消费位置。 */ + const legacyLike = defineConfig({ name: 'legacy', version: '1.0.0', description: 'Legacy.', platforms: official, targets: ['codex'] }); + + void [community, official, config, codexId, legacyLike]; +} diff --git a/packages/test/test/api/sdk-api.types.ts b/packages/test/test/api/sdk-api.types.ts new file mode 100644 index 0000000..3fc7c86 --- /dev/null +++ b/packages/test/test/api/sdk-api.types.ts @@ -0,0 +1,175 @@ +import { + defineConfig, + type UserConfig, +} from '@tokenroll/acplugin'; +// @ts-expect-error Integration factories are intentionally absent from the root author facade. +import { definePlatform as definePlatformFromRoot } from '@tokenroll/acplugin'; +import { + defineExtension, + definePlatform, + snapshotJson, + type AcpluginExtension, + type AcpluginPlatform, + type AssetService, + type ExtensionDefinition, + type FinalizationAssetService, + type PackageContribution, + type PlatformContributor, + type ManagedRolldownInputOptions, + type ManagedRolldownOutputOptions, + type ManagedRolldownPlugin, + type PlatformDefinition, + type SourceFileRef, +} from '@tokenroll/acplugin/sdk'; +// @ts-expect-error Project control types belong to the root author facade, not the integration SDK. +import type { Project } from '@tokenroll/acplugin/sdk'; + +/** + * 验证普通作者根入口和可信集成 SDK 使用不同且明确的类型表面。 + */ +export function verifyKernelV2SdkTypes(): void { + type CommunityComponent = { readonly kind: 'community'; readonly body: string }; + /** 第三方 Platform 只从 sdk subpath 创建。 */ + const platform: AcpluginPlatform, CommunityComponent> = definePlatform({ + id: 'community-platform', + apiVersion: '1', + deliveryType: 'plugin', + /** 每轮创建独立 Platform Session。 */ + createSession: () => ({ + /** 创建空 base Package。 */ + createPackage: () => ({ documents: [], assets: [], compatibility: [], metadata: [] }), + /** 创建主 Plugin Package。 */ + finalizePackage: () => ({ id: 'plugin', type: 'plugin' }), + /** 最小 fixture 不增加 candidate 约束。 */ + validatePackage: () => undefined, + }), + }); + /** Platform-specific component type stays at the contributor/finalization boundary. */ + const contributor: PlatformContributor<{ readonly enabled: true }, CommunityComponent> = { + platform: 'community-platform', + platformApiVersion: '1', + contribute: () => ({ + components: [{ subject: 'community:resource', value: { kind: 'community', body: 'Body.' } }], + compatibility: [{ subject: 'community:resource', capability: 'component', level: 'native', reason: 'Native.' }], + }), + }; + const noComponentContribution: PackageContribution = { compatibility: [] }; + declareFinalizationAssetBoundary(undefined as unknown as FinalizationAssetService); + declareOrdinaryAssetBoundary(undefined as unknown as AssetService); + const unsupportedContributor: PlatformContributor> = { + platform: 'unsupported-platform', + platformApiVersion: '1', + contribute: () => ({ + // @ts-expect-error Default never payload forbids Components for an undeclared Platform capability. + components: [{ subject: 'community:resource', value: { kind: 'community' } }], + compatibility: [], + }), + }; + /** 第三方 Extension 只从 sdk subpath 创建。 */ + const extension: AcpluginExtension = defineExtension({ + id: 'community-extension', + apiVersion: '1', + resourceRoots: ['community'], + /** 每轮创建独立 Extension Session。 */ + createSession: () => ({ + /** fixture 没有作者资源。 */ + discover: () => undefined, + /** fixture 没有 compatibility subject。 */ + validate: () => ({ state: {}, subjects: [] }), + /** fixture 没有 Built State。 */ + build: () => ({ state: {} }), + contributors: [], + }), + }); + /** 作者配置仅消费已完成品牌化的 Integration。 */ + const config: UserConfig = defineConfig({ + name: 'sdk-fixture', + version: '1.0.0', + description: 'SDK fixture.', + platforms: [platform], + extensions: [extension], + }) as UserConfig; + /** defineConfig 必须保留调用方字面量而不是退化成 UserConfig 联合类型。 */ + const inferred = defineConfig({ + name: 'inferred', + version: '1.0.0', + description: 'Inferred.', + platforms: [platform], + build: { strict: true }, + }); + /** 字面量 true 用于验证 const generic inference。 */ + const strictLiteral: true = inferred.build.strict; + /** managed Profile 从 Rolldown 派生可使用的 trusted Plugin hook。 */ + const plugin: ManagedRolldownPlugin = { + name: 'managed-fixture', + transform: code => code, + generateBundle: () => undefined, + }; + /** input 开放浏览器平台、alias 与显式 SourceRef tsconfig。 */ + const managedInput: ManagedRolldownInputOptions = { + platform: 'browser', + resolve: { alias: { feature: './feature.ts' } }, + plugins: [plugin], + tsconfig: undefined as unknown as SourceFileRef, + }; + /** output 开放多格式、分块、sourcemap 与 output Plugin。 */ + const managedOutput: ManagedRolldownOutputOptions = { + format: 'es', + codeSplitting: true, + sourcemap: true, + plugins: [plugin], + }; + /** SDK 为可信集成提供与 Core definition 相同的无行为 JSON snapshot。 */ + const snapshot = snapshotJson({ enabled: true }, 'SDK fixture'); + + // @ts-expect-error Core 从 CompileEntry 建立 input,不接受 Rolldown 裸路径。 + const managedInputEscape: ManagedRolldownInputOptions = { input: '/tmp/escape.ts' }; + // @ts-expect-error Core 接管物理 output dir,只运行 generate()。 + const managedOutputEscape: ManagedRolldownOutputOptions = { dir: '/tmp/escape' }; + // @ts-expect-error write lifecycle 不在 generate-only managed Profile 中假装可用。 + const managedWritePlugin: ManagedRolldownPlugin = { name: 'write', writeBundle: () => undefined }; + + // @ts-expect-error v1 Platform prepare 已从 v2 contract 删除。 + const oldPlatform: PlatformDefinition = { id: 'old', apiVersion: '1', deliveryType: 'plugin', prepare: () => ({}) }; + // @ts-expect-error Extension identity 已统一为 id,不再使用 name。 + const oldExtension: ExtensionDefinition = { name: 'old', apiVersion: '1', resourceRoots: [], createSession: () => ({}) }; + // @ts-expect-error SourceRef 的 private type brand 阻止等形对象在类型层伪造。 + const forgedSource: SourceFileRef = { kind: 'source-file', path: 'src/file.ts' }; + + void [ + config, + contributor, + noComponentContribution, + unsupportedContributor, + oldPlatform, + oldExtension, + forgedSource, + definePlatformFromRoot, + strictLiteral, + managedInput, + managedOutput, + managedInputEscape, + managedOutputEscape, + managedWritePlugin, + snapshot, + undefined as unknown as Project, + ]; +} + +function declareFinalizationAssetBoundary(assets: FinalizationAssetService): void { + void assets.fromBytes({ + bytes: 'component', + origin: { operation: 'platform-component', componentOrigins: [] }, + }); +} + +function declareOrdinaryAssetBoundary(assets: AssetService): void { + void assets.fromBytes({ + bytes: 'component', + origin: { + operation: 'extension-component', + // @ts-expect-error Only Platform finalization may attach Core-issued Component origins. + componentOrigins: [], + }, + }); +} diff --git a/packages/test/test/api/sdk-package-boundary.test.ts b/packages/test/test/api/sdk-package-boundary.test.ts new file mode 100644 index 0000000..7ba45c0 --- /dev/null +++ b/packages/test/test/api/sdk-package-boundary.test.ts @@ -0,0 +1,451 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; + +/** 主包工作区根目录。 */ +const packageRoot = fileURLToPath(new URL('../../../acplugin', import.meta.url)); + +/** 公开 Component payload 类型所在的三个官方 Platform package。 */ +const componentPlatformRoots = Object.freeze({ + 'claude-code': fileURLToPath(new URL('../../../platforms/claude-code', import.meta.url)), + 'cursor': fileURLToPath(new URL('../../../platforms/cursor', import.meta.url)), + 'opencode': fileURLToPath(new URL('../../../platforms/opencode', import.meta.url)), +}); + +/** packed consumer 测试创建的临时目录。 */ +const temporaryRoots: string[] = []; + +/** 执行 clean consumer 子进程并完整捕获文本输出。 */ +async function execute(command: string, args: readonly string[], cwd: string): Promise<{ stdout: string; stderr: string }> { + /** 动态加载避免测试模块初始化时产生子进程副作用。 */ + const { execFile } = await import('node:child_process'); + return new Promise((resolve, reject) => { + execFile(command, [...args], { cwd, maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => { + if (error) + reject(new Error(`${command} ${args.join(' ')} failed: ${stderr || stdout}`, { cause: error })); + else + resolve({ stdout, stderr }); + }); + }); +} + +/** 从 pnpm pack stdout 解析当前 package 的 tarball 绝对路径。 */ +function packedPath(stdout: string, cwd: string): string { + /** pnpm 最后一行是新生成 tarball 的路径。 */ + const output = stdout.trim().split('\n').at(-1)!; + return path.isAbsolute(output) ? output : path.resolve(cwd, output); +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('published sdk package boundary', () => { + it('publishes root and sdk as distinct ESM entries sharing one runtime brand', async () => { + /** 从真实构建产物加载的 root 作者入口。 */ + const root = await import(pathToFileURL(path.join(packageRoot, 'dist/index.mjs')).href); + /** 从真实构建产物加载的 Integration SDK 入口。 */ + const sdk = await import(pathToFileURL(path.join(packageRoot, 'dist/sdk.mjs')).href); + /** SDK 工厂创建并由同一 SDK validator 识别的 Platform。 */ + const platform = sdk.definePlatform({ + id: 'packed-platform', + apiVersion: '1', + deliveryType: 'plugin', + /** packed fixture 使用最小 Session。 */ + createSession: () => ({ + /** packed fixture 创建空 base Package。 */ + createPackage: () => ({ documents: [], assets: [], compatibility: [], metadata: [] }), + /** packed fixture 创建主 Plugin Package。 */ + finalizePackage: () => ({ id: 'plugin', type: 'plugin' }), + /** packed fixture 不增加 candidate 约束。 */ + validatePackage: () => undefined, + }), + }); + + expect(root.definePlatform).toBeUndefined(); + expect(root.defineExtension).toBeUndefined(); + expect(root.defineConfig({ name: 'packed', version: '1.0.0', description: 'Packed.', platforms: [platform] }).platforms[0]).toBe(platform); + expect(sdk.isAcpluginPlatform(platform)).toBe(true); + expect(sdk.LIFECYCLE_API_VERSION).toBe('1'); + }); + + it('packs an installable tarball containing both declared export entries', async () => { + /** pnpm pack 输出所在的隔离目录。 */ + const destination = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-sdk-pack-')); + temporaryRoots.push(destination); + /** 使用 pnpm pack 真实执行 files/export 打包规则。 */ + const { execFile } = await import('node:child_process'); + /** Promise 化子进程避免 shell 插值。 */ + const pack = await new Promise<{ stdout: string }>((resolve, reject) => { + execFile('pnpm', ['pack', '--pack-destination', destination], { cwd: packageRoot }, (error, stdout) => { + if (error) + reject(error); + else + resolve({ stdout }); + }); + }); + /** pnpm 最后一行输出生成的 tarball 路径。 */ + const tarball = pack.stdout.trim().split('\n').at(-1)!; + /** tarball 内容通过系统 tar 只读枚举。 */ + const { stdout: listing } = await new Promise<{ stdout: string }>((resolve, reject) => { + execFile('tar', ['-tf', tarball], (error, stdout) => { + if (error) + reject(error); + else + resolve({ stdout }); + }); + }); + + expect(listing).toContain('package/dist/index.mjs'); + expect(listing).toContain('package/dist/index.d.mts'); + expect(listing).toContain('package/dist/sdk.mjs'); + expect(listing).toContain('package/dist/sdk.d.mts'); + }); + + it('accepts a clean packed SDK-only package exporting both a Platform and Extension', async () => { + /** 所有 package、tarball 与消费工程都位于 workspace 外的同一临时根。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-external-sdk-')); + temporaryRoots.push(root); + /** 主包 tarball 只能来自真实 publish files/exports。 */ + const tarballs = path.join(root, 'tarballs'); + await fs.mkdir(tarballs, { recursive: true }); + /** 当前真实主包 tarball 路径。 */ + const mainTarball = packedPath( + (await execute('pnpm', ['pack', '--pack-destination', tarballs], packageRoot)).stdout, + packageRoot, + ); + /** 当前主包版本决定第三方 Integration 的正常 peer range。 */ + const mainManifest = JSON.parse(await fs.readFile(path.join(packageRoot, 'package.json'), 'utf8')) as { version: string }; + + /** 独立第三方 package 的实现和声明都只从公开 SDK subpath 导入。 */ + const integration = path.join(root, 'external-integration'); + await fs.mkdir(integration, { recursive: true }); + await fs.writeFile(path.join(integration, 'package.json'), JSON.stringify({ + name: 'external-acplugin-integration-fixture', + version: '1.0.0', + type: 'module', + exports: { '.': { types: './index.d.mts', import: './index.mjs' } }, + files: ['index.mjs', 'index.d.mts'], + peerDependencies: { '@tokenroll/acplugin': `^${mainManifest.version}` }, + }, null, 2)); + await fs.writeFile(path.join(integration, 'index.d.mts'), ` +import type { AcpluginExtension, AcpluginPlatform } from '@tokenroll/acplugin/sdk'; +export declare const externalPlatform: AcpluginPlatform; +export declare const externalExtension: AcpluginExtension; +`); + await fs.writeFile(path.join(integration, 'index.mjs'), ` +import { defineExtension, definePlatform } from '@tokenroll/acplugin/sdk'; + +const metadata = () => ['name', 'version', 'description'].map(field => ({ + field, + disposition: 'emitted', + output: \`plugin.json/\${field}\`, + reason: 'The external fixture emits this canonical field.', +})); + +export const externalPlatform = definePlatform({ + id: 'external-fixture', + apiVersion: '1', + deliveryType: 'plugin', + createSession: () => ({ + createPackage: ({ project }) => ({ + documents: [{ + id: 'plugin-manifest', + path: 'plugin.json', + format: 'json', + value: { + name: project.metadata.name, + version: project.metadata.version, + description: project.metadata.description, + extensions: {}, + }, + extensionPoints: [['extensions', 'external']], + }], + assets: [], + compatibility: [], + metadata: metadata(), + }), + finalizePackage: () => ({ id: 'plugin', type: 'plugin' }), + validatePackage: () => undefined, + }), +}); + +export const externalExtension = defineExtension({ + id: 'external-fixture', + apiVersion: '1', + resourceRoots: ['external'], + createSession: () => ({ + async discover({ roots, sources }) { + const root = roots.external; + return root === undefined ? undefined : { file: await sources.file(root, 'state.txt') }; + }, + validate: (_context, discovered) => ({ + state: discovered, + subjects: [{ subject: 'external:state', capabilities: ['delivery'] }], + }), + async build({ assets }, validated) { + return { state: { asset: await assets.fromSource(validated.file) } }; + }, + contributors: [{ + platform: 'external-fixture', + platformApiVersion: '1', + contribute: (_context, built) => ({ + documentFields: [{ + document: 'plugin-manifest', + path: ['extensions', 'external'], + value: { enabled: true }, + }], + assets: [{ path: 'external/state.txt', asset: built.asset }], + compatibility: [{ + subject: 'external:state', + capability: 'delivery', + level: 'native', + reason: 'The external fixture contributes through the public SDK.', + }], + }), + }], + }), +}); +`); + /** 第三方 tarball 不得声明私有 Core 或另一个 Integration runtime dependency。 */ + const integrationTarball = packedPath( + (await execute('pnpm', ['pack', '--pack-destination', tarballs], integration)).stdout, + integration, + ); + /** 从第三方 tarball 原始清单读取公开依赖边界。 */ + const packedManifest = JSON.parse((await execute('tar', ['-xOf', integrationTarball, 'package/package.json'], root)).stdout) as { + dependencies?: Record; + peerDependencies?: Record; + }; + expect(packedManifest.dependencies).toBeUndefined(); + expect(packedManifest.peerDependencies).toEqual({ '@tokenroll/acplugin': `^${mainManifest.version}` }); + expect(JSON.stringify(packedManifest)).not.toContain('@acplugin/'); + + /** clean consumer 只安装两个 tarball,不依赖 workspace alias 或私有 Core。 */ + const consumer = path.join(root, 'consumer'); + await fs.mkdir(path.join(consumer, 'src/external'), { recursive: true }); + await fs.writeFile(path.join(consumer, 'package.json'), JSON.stringify({ + name: 'external-acplugin-consumer', + version: '1.0.0', + private: true, + type: 'module', + dependencies: { + '@tokenroll/acplugin': `file:${mainTarball}`, + 'external-acplugin-integration-fixture': `file:${integrationTarball}`, + }, + devDependencies: { '@typescript/native': 'npm:typescript@^7.0.2' }, + }, null, 2)); + await fs.writeFile(path.join(consumer, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { + target: 'ES2022', + lib: ['ESNext', 'DOM'], + module: 'NodeNext', + moduleResolution: 'NodeNext', + strict: true, + noEmit: true, + skipLibCheck: false, + }, + include: ['acplugin.config.ts'], + }, null, 2)); + await fs.writeFile(path.join(consumer, 'src/external/state.txt'), 'external-ready\n'); + await fs.writeFile(path.join(consumer, 'acplugin.config.ts'), ` +import { defineConfig } from '@tokenroll/acplugin'; +import { externalExtension, externalPlatform } from 'external-acplugin-integration-fixture'; + +export default defineConfig({ + name: 'external-consumer', + version: '1.0.0', + description: 'Clean external SDK consumer.', + public: false, + platforms: [externalPlatform], + extensions: [externalExtension], +}); +`); + await execute('pnpm', ['install', '--ignore-workspace', '--ignore-scripts'], consumer); + await execute('pnpm', ['exec', 'tsc', '-p', 'tsconfig.json'], consumer); + + /** SDK identity helpers必须识别第三方 package 导出的两个 factory result。 */ + const identity = await execute(process.execPath, ['--input-type=module', '--eval', ` +import { isAcpluginExtension, isAcpluginPlatform } from '@tokenroll/acplugin/sdk'; +import { externalExtension, externalPlatform } from 'external-acplugin-integration-fixture'; +process.stdout.write(JSON.stringify({ + platform: isAcpluginPlatform(externalPlatform), + extension: isAcpluginExtension(externalExtension), +})); +`], consumer); + expect(JSON.parse(identity.stdout)).toEqual({ platform: true, extension: true }); + + /** validate 和 build 必须通过同一安装后的 CLI/Kernel 生命周期。 */ + const validate = JSON.parse((await execute('pnpm', ['exec', 'acplugin', 'validate', '--json'], consumer)).stdout); + /** build 报告用于验证真实事务和最终 Package。 */ + const build = JSON.parse((await execute('pnpm', ['exec', 'acplugin', 'build', '--json'], consumer)).stdout); + expect(validate).toMatchObject({ success: true, committed: false }); + expect(build).toMatchObject({ success: true, committed: true }); + expect(build.compatibility).toContainEqual(expect.objectContaining({ + platform: 'external-fixture', + subject: 'external:state', + capability: 'delivery', + level: 'native', + })); + expect(build.packages).toContainEqual(expect.objectContaining({ + platform: 'external-fixture', + id: 'plugin', + validated: true, + assets: expect.arrayContaining([ + expect.objectContaining({ path: 'plugin.json', owner: 'platform:external-fixture' }), + expect.objectContaining({ path: 'external/state.txt', owner: 'extension:external-fixture' }), + ]), + })); + await expect(fs.readFile(path.join(consumer, 'dist/external-fixture/plugin/external/state.txt'), 'utf8')).resolves.toBe('external-ready\n'); + /** 最终 Manifest 必须包含 Core 合并后的 add-only Document 字段。 */ + const manifest = JSON.parse(await fs.readFile(path.join(consumer, 'dist/external-fixture/plugin/plugin.json'), 'utf8')); + expect(manifest.extensions).toEqual({ external: { enabled: true } }); + }, 120_000); + + it('typechecks and builds official Platform Component payloads from clean tarballs', async () => { + /** 主包、Platform tarball 和 consumer 全部位于 workspace 外。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-platform-components-pack-')); + temporaryRoots.push(root); + const tarballs = path.join(root, 'tarballs'); + await fs.mkdir(tarballs); + + /** 所有安装输入都经过各公开 package 的真实 pnpm pack 边界。 */ + const mainTarball = packedPath( + (await execute('pnpm', ['pack', '--pack-destination', tarballs], packageRoot)).stdout, + packageRoot, + ); + const platformTarballs = Object.fromEntries(await Promise.all( + Object.entries(componentPlatformRoots).map(async ([id, packageDirectory]) => [ + id, + packedPath( + (await execute('pnpm', ['pack', '--pack-destination', tarballs], packageDirectory)).stdout, + packageDirectory, + ), + ]), + )); + + /** clean consumer 不可见 workspace alias、私有 Core 或源码声明。 */ + const consumer = path.join(root, 'consumer'); + await fs.mkdir(consumer); + await fs.writeFile(path.join(consumer, 'package.json'), JSON.stringify({ + name: 'official-platform-component-consumer', + version: '1.0.0', + private: true, + type: 'module', + dependencies: { + '@tokenroll/acplugin': `file:${mainTarball}`, + '@tokenroll/acplugin-platform-claude-code': `file:${platformTarballs['claude-code']}`, + '@tokenroll/acplugin-platform-cursor': `file:${platformTarballs.cursor}`, + '@tokenroll/acplugin-platform-opencode': `file:${platformTarballs.opencode}`, + }, + devDependencies: { '@typescript/native': 'npm:typescript@^7.0.2' }, + }, null, 2)); + await fs.writeFile(path.join(consumer, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { + target: 'ES2022', + lib: ['ESNext', 'DOM'], + module: 'NodeNext', + moduleResolution: 'NodeNext', + strict: true, + noEmit: true, + skipLibCheck: false, + }, + include: ['acplugin.config.ts'], + }, null, 2)); + await fs.writeFile(path.join(consumer, 'acplugin.config.ts'), ` +import { defineConfig } from '@tokenroll/acplugin'; +import { defineExtension, type PlatformContributor } from '@tokenroll/acplugin/sdk'; +import claudeCode, { type ClaudePackageComponent } from '@tokenroll/acplugin-platform-claude-code'; +import cursor, { type CursorPackageComponent } from '@tokenroll/acplugin-platform-cursor'; +import openCode, { type OpenCodePackageComponent } from '@tokenroll/acplugin-platform-opencode'; + +type BuiltState = Record; +const subject = 'fixture:packed-agent'; +const compatibility = [{ + subject, + capability: 'delivery', + level: 'native', + reason: 'The packed fixture is delivered as a native Platform Component.', +}] as const; + +const claudeContributor: PlatformContributor = { + platform: 'claude-code', + platformApiVersion: '1', + contribute: () => ({ + components: [{ subject, value: { + kind: 'native-agent', id: 'packed-agent', description: 'Packed Agent.', body: 'Run packed checks.', + model: 'capable', tools: ['Read'], + } }], + compatibility, + }), +}; + +const cursorContributor: PlatformContributor = { + platform: 'cursor', + platformApiVersion: '1', + contribute: () => ({ + components: [{ subject, value: { + kind: 'native-agent', id: 'packed-agent', description: 'Packed Agent.', body: 'Run packed checks.', + readonly: true, + } }], + compatibility, + }), +}; + +const openCodeContributor: PlatformContributor = { + platform: 'opencode', + platformApiVersion: '1', + contribute: () => ({ + components: [{ subject, value: { + kind: 'native-agent', id: 'packed-agent', description: 'Packed Agent.', body: 'Run packed checks.', + tools: { read: true }, permission: { edit: 'deny' }, + } }], + compatibility, + }), +}; + +const extension = defineExtension({ + id: 'packed-component-fixture', + apiVersion: '1', + resourceRoots: [], + createSession: () => ({ + discover: () => ({}), + validate: (_context, state) => ({ state, subjects: [{ subject, capabilities: ['delivery'] }] }), + build: (_context, state) => ({ state }), + contributors: [claudeContributor, cursorContributor, openCodeContributor], + }), +}); + +export default defineConfig({ + name: 'packed-component-consumer', + version: '1.0.0', + description: 'Clean packed Platform Component consumer.', + public: false, + platforms: [claudeCode(), cursor(), openCode()], + extensions: [extension], +}); +`); + + await execute('pnpm', ['install', '--ignore-workspace', '--ignore-scripts'], consumer); + await execute('pnpm', ['exec', 'tsc', '-p', 'tsconfig.json'], consumer); + const build = JSON.parse((await execute('pnpm', ['exec', 'acplugin', 'build', '--json'], consumer)).stdout); + + expect(build).toMatchObject({ success: true, committed: true, schemaVersion: 3 }); + expect(build.packages).toEqual(expect.arrayContaining([ + expect.objectContaining({ + platform: 'claude-code', + assets: expect.arrayContaining([expect.objectContaining({ path: 'agents/packed-agent.md' })]), + }), + expect.objectContaining({ + platform: 'cursor', + assets: expect.arrayContaining([expect.objectContaining({ path: 'agents/packed-agent.md' })]), + }), + expect.objectContaining({ + platform: 'opencode', + assets: expect.arrayContaining([expect.objectContaining({ path: '.opencode/agents/packed-agent.md' })]), + }), + ])); + }, 120_000); +}); diff --git a/packages/test/test/architecture/architecture.test.ts b/packages/test/test/architecture/architecture.test.ts new file mode 100644 index 0000000..9f0a130 --- /dev/null +++ b/packages/test/test/architecture/architecture.test.ts @@ -0,0 +1,130 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +/** 架构残留扫描使用的仓库绝对根目录。 */ +const root = fileURLToPath(new URL('../../../..', import.meta.url)); + +/** 生产源码中不允许继续存在的旧运行时类型和投影字段。 */ +const RETIRED_RUNTIME_PATTERN = /\b(?:TargetId|ResolvedTarget|AcpluginModule|TargetContribution|CompilerContext|CompilerOutput|CompilerRegistry|BuildRequest|legacyTargets|legacyModules|buildProject|ArtifactGraph)\b/; + +/** 已正式删除且不得被 Workspace 依赖重新引入的旧包名。 */ +const RETIRED_PACKAGE_PATTERN = /@acplugin\/compiler-|@tokenroll\/acplugin-module-/; + +/** 允许保留旧字段文字、但只能用于定向诊断或迁移的生产源码。 */ +const LEGACY_TERM_ALLOWLIST = new Set([ + 'packages/acplugin/src/cli/program.ts', +]); + +/** + * 递归收集生产目录下的 TypeScript 源文件。 + * + * @param directory 当前遍历目录。 + * @returns 按仓库相对路径排序的 TypeScript 文件。 + */ +async function productionFiles(directory: string): Promise { + /** 当前目录按名称确定性排序后的目录项。 */ + const entries = await fs.readdir(path.join(root, directory), { withFileTypes: true }); + /** 当前目录及其后代累计得到的生产源码。 */ + const files: string[] = []; + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name, 'en'))) { + /** 当前目录项的仓库相对路径。 */ + const relative = path.posix.join(directory, entry.name); + if (entry.isDirectory()) + files.push(...await productionFiles(relative)); + else if (entry.name.endsWith('.ts')) + files.push(relative); + } + return files; +} + +/** + * 递归收集可能保留 Workspace 依赖、路径 Alias 或 Bundle 入口的元数据文件。 + * + * @param directory 当前遍历的仓库相对目录。 + * @returns 排除依赖与构建产物后的稳定元数据文件列表。 + */ +async function workspaceMetadataFiles(directory: string): Promise { + /** 当前目录按名称确定性排序后的目录项。 */ + const entries = await fs.readdir(path.join(root, directory), { withFileTypes: true }); + /** 当前目录及后代累计得到的 Workspace 元数据文件。 */ + const files: string[] = []; + /** entry 表示当前检查的目录项。 */ + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name, 'en'))) { + if (entry.name === 'node_modules' || entry.name === 'dist') + continue; + /** 当前目录项的仓库相对路径。 */ + const relative = path.posix.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...await workspaceMetadataFiles(relative)); + } else if (entry.name === 'package.json' || entry.name === 'tsconfig.json' + || entry.name === 'tsdown.config.ts' || entry.name === 'vitest.config.ts') { + files.push(relative); + } + } + return files; +} + +describe('retired runtime architecture guard', () => { + it('keeps production on the single Platform/Extension lifecycle', async () => { + /** Core、主包、Platform 和 Extension 共同构成的生产 TypeScript 范围。 */ + const files = (await Promise.all([ + 'packages/core/src', + 'packages/acplugin/src', + 'packages/platforms', + 'packages/extensions', + ].map(productionFiles))).flat(); + /** 每个违规文件及其命中类别组成的稳定列表。 */ + const violations: string[] = []; + for (const file of files) { + /** 当前生产源码的完整文本。 */ + const source = await fs.readFile(path.join(root, file), 'utf8'); + /** Migration 是旧输入术语唯一允许存在的生产隔离区。 */ + const isMigration = file.startsWith('packages/acplugin/src/migration/'); + if (!isMigration && RETIRED_RUNTIME_PATTERN.test(source)) + violations.push(`${file}:runtime`); + if (!isMigration && RETIRED_PACKAGE_PATTERN.test(source)) + violations.push(`${file}:package`); + if (!isMigration && !LEGACY_TERM_ALLOWLIST.has(file) && /(?:--target|["']targets["']|["']modules["'])/.test(source)) + violations.push(`${file}:term`); + } + + expect(violations).toEqual([]); + }); + + it('does not retain the old Compiler or Module package directories', async () => { + /** ACPL-012 必须从 Workspace 物理删除的旧包目录。 */ + const retiredDirectories = [ + 'packages/compiler-claude-code', + 'packages/compiler-codex', + 'packages/module-hooks', + 'packages/module-mcp', + ]; + for (const directory of retiredDirectories) + await expect(fs.access(path.join(root, directory))).rejects.toThrow(); + }); + + it('does not retain retired package names in Workspace and release metadata', async () => { + /** 根配置与递归 Package 元数据共同覆盖依赖、Alias、Bundle、Changesets 和 Lockfile。 */ + const files = [ + '.changeset/config.json', + 'package.json', + 'pnpm-lock.yaml', + 'pnpm-workspace.yaml', + 'tsconfig.base.json', + ...await workspaceMetadataFiles('packages'), + ]; + /** 仍包含已删除包名的元数据文件。 */ + const violations: string[] = []; + /** file 表示当前检查的 Workspace 元数据文件。 */ + for (const file of files) { + /** 当前元数据文件的完整文本。 */ + const source = await fs.readFile(path.join(root, file), 'utf8'); + if (RETIRED_PACKAGE_PATTERN.test(source)) + violations.push(file); + } + + expect(violations).toEqual([]); + }); +}); diff --git a/packages/test/test/architecture/integration-boundaries.test.ts b/packages/test/test/architecture/integration-boundaries.test.ts new file mode 100644 index 0000000..dacc2b7 --- /dev/null +++ b/packages/test/test/architecture/integration-boundaries.test.ts @@ -0,0 +1,208 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +/** Integration 架构边界守卫扫描时使用的仓库根目录。 */ +const root = fileURLToPath(new URL('../../../..', import.meta.url)); + +/** 正式生产源码中不得继续新增的 v1 模型或命令式 Context API。 */ +const v1ArchitecturePattern = /\b(?:executeLifecycle|PlatformDraft|DeliveryUnit|ExtensionPlatformAdapter|ProjectBuildService|ProjectModuleService|emitArtifact|patchDocument)\b/; + +/** Platform/Extension 迁移到 SDK subpath 前允许保留根入口导入的精确文件集合。 */ +const rootSdkImportPattern = /(?:from\s+['"]@tokenroll\/acplugin['"]|import\(\s*['"]@tokenroll\/acplugin['"])/; + +/** 只有 Core Compiler Host 可以直接驱动 Rolldown。 */ +const directRolldownPattern = /(?:from\s+['"](?:rolldown|@rolldown\/)|import\(\s*['"](?:rolldown|@rolldown\/))/; + +/** 生产源码中 Chokidar 只能由 Core DevSession 直接拥有。 */ +const watcherPattern = /(?:from\s+['"]chokidar['"]|import\(\s*['"]chokidar['"])/; + +/** 主包 bundle 私有 Core 时允许保留的精确源码入口。 */ +const privateCoreImportPattern = /(?:from\s+['"]@acplugin\/core(?:\/[^'"]+)?['"]|import\(\s*['"]@acplugin\/core(?:\/[^'"]+)?['"])/; + +/** 底层 Registry 不得动态或为运行时值反向加载 Compiler/lifecycle。 */ +function hasServiceLayerRuntimeImport(source: string): boolean { + if (/import\s*\(\s*['"]\.\.\/(?:compiler|lifecycle)\//u.test(source) + || /^[ \t]*import[ \t]*['"]\.\.\/(?:compiler|lifecycle)\//mu.test(source)) { + return true; + } + /** 每个指向上层的静态 import clause 用于区分 value 与纯 type specifier。 */ + const staticImports = source.matchAll(/^[ \t]*import\s+([^;]*?)\s+from\s+['"]\.\.\/(?:compiler|lifecycle)\//gmu); + for (const match of staticImports) { + /** import type 声明整体不会建立运行时依赖。 */ + const clause = match[1]!.trim(); + if (/^type\b/u.test(clause)) + continue; + /** 命名 import 只有全部 specifier 都带 inline type 时才是纯类型依赖。 */ + const named = /^\{([\s\S]*)\}$/u.exec(clause); + if (named !== null && named[1]!.split(',').every(specifier => /^type\b/u.test(specifier.trim()))) + continue; + return true; + } + return false; +} + +/** 架构重构完成后正式生产源码不允许保留任何 v1 架构符号。 */ +const v1ArchitectureAllowlist = [] as const; + +/** Integration 根入口导入的固定基线;对应包迁移后必须从列表删除。 */ +const rootSdkImportAllowlist = [] as const; + +/** Core 只允许 Compiler driver 与 SDK type contract 直接引用精确 Rolldown 包。 */ +const directRolldownAllowlist = [ + 'packages/core/src/compiler/engine-loader.ts', + 'packages/core/src/contracts/compiler.ts', +] as const; + +/** CLI 与 Integration 不得建立第二个 watcher owner。 */ +const watcherAllowlist = [ + 'packages/core/src/lifecycle/dev-session.ts', +] as const; + +/** 主包构建期间允许引用私有 Core 的精确入口。 */ +const privateCoreImportAllowlist = [ + 'packages/acplugin/src/author/project.ts', + 'packages/acplugin/src/index.ts', + 'packages/acplugin/src/sdk.ts', +] as const; + +/** 架构扫描覆盖的正式源码根,不包含容错型 Migration legacy。 */ +const productionRoots = [ + 'packages/core/src', + 'packages/acplugin/src', + 'packages/platforms/claude-code/src', + 'packages/platforms/codex/src', + 'packages/platforms/cursor/src', + 'packages/platforms/antigravity/src', + 'packages/platforms/opencode/src', + 'packages/platforms/pi/src', + 'packages/extensions/hooks/src', + 'packages/extensions/mcp/src', +] as const; + +/** + * 递归收集正式 TypeScript 源码。 + * + * @param directory 当前仓库相对目录。 + * @returns 按 UTF-16 code unit 排序的仓库相对文件列表。 + */ +async function sourceFiles(directory: string): Promise { + /** 当前目录按名称排序后的目录项。 */ + const entries = await fs.readdir(path.join(root, directory), { withFileTypes: true }); + /** 当前目录与所有后代的 TypeScript 源码。 */ + const files: string[] = []; + /** entry 表示当前遍历的稳定排序目录项。 */ + for (const entry of entries.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0)) { + /** 当前目录项的仓库相对 POSIX 路径。 */ + const relative = path.posix.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...await sourceFiles(relative)); + } else if (/\.(?:ts|mts|cts)$/.test(entry.name)) { + files.push(relative); + } + } + return files; +} + +/** + * 返回命中某个架构模式的正式生产源码。 + * + * @param pattern 需要扫描的无状态正则表达式。 + * @returns 稳定排序且排除 Migration 的命中文件。 + */ +async function matchingFiles(pattern: RegExp): Promise { + /** 所有生产源码根递归得到的候选文件。 */ + const files = (await Promise.all(productionRoots.map(sourceFiles))).flat(); + /** 每个候选文件的路径与源码。 */ + const sources = await Promise.all(files.map(async file => ({ file, source: await fs.readFile(path.join(root, file), 'utf8') }))); + return sources + .filter(({ file, source }) => !file.startsWith('packages/acplugin/src/migration/') && pattern.test(source)) + .map(({ file }) => file) + .sort(); +} + +/** 返回 services 生产文件中命中层级反向依赖的稳定路径。 */ +async function matchingServiceLayerImports(): Promise { + /** 只检查 Core services 本身,Compiler 可以合法消费这些 registry。 */ + const files = await sourceFiles('packages/core/src/services'); + /** 每个 Service 源码与其路径配对后检查静态 import。 */ + const sources = await Promise.all(files.map(async file => ({ file, source: await fs.readFile(path.join(root, file), 'utf8') }))); + return sources + .filter(({ source }) => hasServiceLayerRuntimeImport(source)) + .map(({ file }) => file) + .sort(); +} + +describe('Integration architecture boundary guard', () => { + it('only shrinks the exact v1 architecture baseline', async () => { + expect(await matchingFiles(v1ArchitecturePattern)).toEqual([...v1ArchitectureAllowlist].sort()); + }); + + it('moves integrations from the root facade to the SDK subpath without new root imports', async () => { + /** Integration 源码才受 SDK subpath 规则约束;主包 init 的作者示例合法使用根入口。 */ + const matches = (await matchingFiles(rootSdkImportPattern)).filter(file => file.startsWith('packages/platforms/') || file.startsWith('packages/extensions/')); + expect(matches).toEqual([...rootSdkImportAllowlist].sort()); + }); + + it('keeps direct Rolldown imports confined to the Core driver and type contract', async () => { + expect(await matchingFiles(directRolldownPattern)).toEqual([...directRolldownAllowlist].sort()); + }); + + it('keeps Core DevSession as the only watcher owner', async () => { + expect(await matchingFiles(watcherPattern)).toEqual([...watcherAllowlist].sort()); + }); + + it('keeps private Core imports confined to the bundled main package facade', async () => { + expect(await matchingFiles(privateCoreImportPattern)).toEqual([...privateCoreImportAllowlist].sort()); + }); + + it('keeps Core registries below compiler and lifecycle orchestration', async () => { + expect(await matchingServiceLayerImports()).toEqual([]); + }); + + it('recognizes every runtime import form in the service-layer guard', () => { + /** 普通、side-effect 与动态 import 都会建立运行时依赖。 */ + const runtimeImports = [ + 'import { CompilerHost } from "../compiler/compiler-service.js";', + 'import "../lifecycle/build-session.js";', + 'const module = await import("../compiler/module-host.js");', + ]; + for (const source of runtimeImports) + expect(hasServiceLayerRuntimeImport(source)).toBe(true); + expect(hasServiceLayerRuntimeImport('import { type Scope, CompilerHost } from "../compiler/compiler-service.js";')).toBe(true); + expect(hasServiceLayerRuntimeImport('import type { Scope } from "../services/types.js";\nimport { CompilerHost } from "../compiler/compiler-service.js";')).toBe(true); + /** 整体或逐 specifier 的纯类型依赖和同层 Service 依赖都不违反运行时层级。 */ + expect(hasServiceLayerRuntimeImport('import type { KernelBuildEnvironment } from "../lifecycle/build-environment.js";')).toBe(false); + expect(hasServiceLayerRuntimeImport('import { type KernelBuildEnvironment } from "../lifecycle/build-environment.js";')).toBe(false); + expect(hasServiceLayerRuntimeImport('import { type Scope, type Token as Identity } from "../compiler/types.js";')).toBe(false); + expect(hasServiceLayerRuntimeImport('import { SourceRegistry } from "../services/sources.js";')).toBe(false); + }); + + it('keeps the removed Node Runtime Extension absent from the workspace', async () => { + await expect(fs.access(path.join(root, 'packages/extensions/node-runtime'))).rejects.toThrow(); + }); + + it('keeps replaced Scanner and config implementations absent', async () => { + await expect(fs.access(path.join(root, 'packages/core/src/scanner.ts'))).rejects.toThrow(); + await expect(fs.access(path.join(root, 'packages/core/src/config.ts'))).rejects.toThrow(); + /** 全部正式生产源码用于检查解析后仍指向旧根模块的相对导入。 */ + const production = (await Promise.all(productionRoots.map(sourceFiles))).flat(); + /** 文件文本与路径配对后执行精确旧导入扫描。 */ + const imports = await Promise.all(production.map(async file => ({ + file, + source: await fs.readFile(path.join(root, file), 'utf8'), + }))); + /** 任意相对层级的 import specifier 都按源文件目录解析后再与旧根路径比较。 */ + const staleImports = imports.flatMap(({ file, source }) => [...source.matchAll(/from\s+['"](\.\.?\/(?:[^'"]+\/)*(?:scanner|config)\.js)['"]/gu)] + .filter(match => ['packages/core/src/scanner.js', 'packages/core/src/config.js'].includes(path.posix.normalize(path.posix.join(path.posix.dirname(file), match[1]!)))) + .map(() => file)); + expect(staleImports).toEqual([]); + }); + + it('keeps the lifecycle API version at one during the beta rewrite', async () => { + /** Core 基础契约中的 API version 是新架构的单一源码断言。 */ + const source = await fs.readFile(path.join(root, 'packages/core/src/contracts/common.ts'), 'utf8'); + expect(source).toContain('export const LIFECYCLE_API_VERSION = \'1\' as const;'); + }); +}); diff --git a/packages/test/test/architecture/repository.test.ts b/packages/test/test/architecture/repository.test.ts new file mode 100644 index 0000000..f934182 --- /dev/null +++ b/packages/test/test/architecture/repository.test.ts @@ -0,0 +1,149 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +/** 当前 monorepo 根目录,用于读取工作流、清单与稳定文档。 */ +const root = fileURLToPath(new URL('../../../..', import.meta.url)); + +/** + * 读取仓库根目录下的 UTF-8 文件。 + * + * @param relativePath 仓库相对路径。 + * @returns 文件内容。 + */ +async function read(relativePath: string): Promise { + return fs.readFile(path.join(root, relativePath), 'utf8'); +} + +describe('repository release and documentation guards', () => { + it('keeps nine independently versioned packages manually publishable', async () => { + /** 九个公开包的清单路径。 */ + const packageFiles = [ + 'packages/acplugin/package.json', + 'packages/platforms/claude-code/package.json', + 'packages/platforms/codex/package.json', + 'packages/platforms/cursor/package.json', + 'packages/platforms/antigravity/package.json', + 'packages/platforms/opencode/package.json', + 'packages/platforms/pi/package.json', + 'packages/extensions/hooks/package.json', + 'packages/extensions/mcp/package.json', + ]; + /** 九个公开包解析后的发布字段。 */ + const manifests = await Promise.all(packageFiles.map(async file => JSON.parse(await read(file)) as { + name: string; + version: string; + private?: boolean; + publishConfig?: { access?: string; provenance?: boolean }; + })); + /** Changesets 不再声明固定版本发布组。 */ + const changeset = JSON.parse(await read('.changeset/config.json')) as { fixed: string[][] }; + + expect(manifests.map(manifest => manifest.name)).toEqual([ + '@tokenroll/acplugin', + '@tokenroll/acplugin-platform-claude-code', + '@tokenroll/acplugin-platform-codex', + '@tokenroll/acplugin-platform-cursor', + '@tokenroll/acplugin-platform-antigravity', + '@tokenroll/acplugin-platform-opencode', + '@tokenroll/acplugin-platform-pi', + '@tokenroll/acplugin-extension-hooks', + '@tokenroll/acplugin-extension-mcp', + ]); + expect(manifests.every(manifest => manifest.private !== true)).toBe(true); + expect(manifests.every(manifest => manifest.publishConfig?.access === 'public')).toBe(true); + expect(manifests.every(manifest => manifest.publishConfig?.provenance === undefined)).toBe(true); + expect(changeset.fixed).toEqual([]); + }); + + it('keeps pull-request checks, version PRs, and stable publication separate', async () => { + /** 四条有意保持单一职责的发行工作流。 */ + const [lint, typecheck, changelog, release] = await Promise.all([ + read('.github/workflows/lint.yml'), + read('.github/workflows/typecheck.yml'), + read('.github/workflows/changelog.yml'), + read('.github/workflows/release.yml'), + ]); + /** 根命令定义本地 beta 与手工 stable 的同一发布边界。 */ + const manifest = JSON.parse(await read('package.json')) as { scripts?: Record }; + + expect(lint).toContain('pull_request:'); + expect(lint).toContain('pnpm run lint'); + expect(lint).not.toMatch(/(?:pnpm|npm) publish|NPM_TOKEN|changesets\/action/u); + expect(typecheck).toContain('pull_request:'); + expect(typecheck).toContain('pnpm run typecheck'); + expect(typecheck).not.toMatch(/(?:pnpm|npm) publish|NPM_TOKEN|changesets\/action/u); + + expect(changelog).toContain('push:'); + expect(changelog).toContain('branches: [main]'); + expect(changelog).toContain('changesets/action@v1'); + expect(changelog).toContain('version: pnpm run version-packages'); + expect(changelog).not.toMatch(/(?:pnpm|npm) publish|NPM_TOKEN/u); + + expect(release).toContain('workflow_dispatch:'); + expect(release).not.toMatch(/\b(?:pull_request|push):/u); + expect(release).toContain('pnpm run release'); + expect(release).toContain('NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}'); + + expect(manifest.scripts?.['publish:beta:dry-run']).toContain('pnpm -r --filter \'@tokenroll/*\' publish'); + expect(manifest.scripts?.['publish:beta']).toContain('--tag beta'); + expect(manifest.scripts?.release).toContain('--tag latest'); + expect(manifest.scripts?.['publish:beta']).toContain('--ignore-scripts'); + expect(manifest.scripts?.release).toContain('--ignore-scripts'); + }); + + it('separates the repository Node toolchain from published runtime support', async () => { + /** 根工具链和九个公开包的精确清单路径。 */ + const files = [ + 'package.json', + 'packages/acplugin/package.json', + 'packages/platforms/claude-code/package.json', + 'packages/platforms/codex/package.json', + 'packages/platforms/cursor/package.json', + 'packages/platforms/antigravity/package.json', + 'packages/platforms/opencode/package.json', + 'packages/platforms/pi/package.json', + 'packages/extensions/hooks/package.json', + 'packages/extensions/mcp/package.json', + ]; + /** 当前根与公开 manifest 的 engine/dependency 边界。 */ + const [repository, main, ...integrations] = await Promise.all(files.map(async file => JSON.parse(await read(file)) as { + engines?: { node?: string }; + dependencies?: Record; + })); + + expect(repository.engines?.node).toBe('^22.18.0 || >=24.11.0'); + for (const manifest of [main, ...integrations]) + expect(manifest.engines?.node).toBe('^20.19.0 || ^22.13.0 || >=23.5.0'); + expect(main.dependencies?.commander).toBe('14.0.1'); + }); + + it('keeps current docs free of the retired namespace and CLI', async () => { + /** 当前需要同步且不得残留旧命名的稳定文档集合。 */ + const docs = await Promise.all([ + 'README.md', + 'README.zh-CN.md', + 'AGENTS.md', + 'llmdoc/index.md', + 'llmdoc/startup.md', + 'llmdoc/overview/project.md', + 'llmdoc/overview/project.zh-CN.md', + 'llmdoc/architecture/system.md', + 'llmdoc/architecture/system.zh-CN.md', + 'llmdoc/guides/usage.md', + 'llmdoc/guides/usage.zh-CN.md', + 'llmdoc/guides/release.md', + 'llmdoc/guides/release.zh-CN.md', + 'llmdoc/reference/conversion-matrix.md', + 'llmdoc/reference/conversion-matrix.zh-CN.md', + ].map(read)); + /** 便于统一扫描旧 namespace、命令和路径的文档文本。 */ + const currentDocumentation = docs.join('\n'); + + expect(currentDocumentation).not.toContain('@disdjj/acplugin'); + expect(currentDocumentation).not.toMatch(/\bacplugin (?:scan|convert)\b/); + expect(currentDocumentation).not.toContain('src/converter/'); + expect(currentDocumentation).not.toContain('.github/workflows/acplugin.yml'); + }); +}); diff --git a/packages/test/test/architecture/workspace-layout.test.ts b/packages/test/test/architecture/workspace-layout.test.ts new file mode 100644 index 0000000..25c7f8d --- /dev/null +++ b/packages/test/test/architecture/workspace-layout.test.ts @@ -0,0 +1,100 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +/** Workspace 边界测试读取的仓库绝对根目录。 */ +const root = fileURLToPath(new URL('../../../..', import.meta.url)); + +/** 六个官方 Platform 的目录名与公开包名。 */ +const platformPackages = [ + ['claude-code', '@tokenroll/acplugin-platform-claude-code'], + ['codex', '@tokenroll/acplugin-platform-codex'], + ['cursor', '@tokenroll/acplugin-platform-cursor'], + ['antigravity', '@tokenroll/acplugin-platform-antigravity'], + ['opencode', '@tokenroll/acplugin-platform-opencode'], + ['pi', '@tokenroll/acplugin-platform-pi'], +] as const; + +/** 九个独立版本的正式公开包清单路径。 */ +const publicPackageFiles = [ + 'packages/acplugin/package.json', + ...platformPackages.map(([directory]) => `packages/platforms/${directory}/package.json`), + 'packages/extensions/hooks/package.json', + 'packages/extensions/mcp/package.json', +] as const; + +/** Workspace 边界断言需要读取的 package.json 字段。 */ +interface PackageManifest { + name: string; + version: string; + private?: boolean; + dependencies?: Record; + peerDependencies?: Record; + devDependencies?: Record; +} + +/** + * 读取仓库内一个 JSON 文件。 + * + * @param relativePath 仓库相对路径。 + * @returns 解析后的 JSON 值。 + */ +async function readJson(relativePath: string): Promise { + /** JSON 文件的 UTF-8 原文。 */ + const source = await fs.readFile(path.join(root, relativePath), 'utf8'); + return JSON.parse(source) as T; +} + +describe('final workspace skeleton', () => { + it('declares every official Platform as an independent public peer package', async () => { + /** 六个 Platform 实际读取到的包清单。 */ + const manifests = await Promise.all(platformPackages.map(async ([directory]) => readJson(`packages/platforms/${directory}/package.json`))); + + expect(manifests.map(manifest => manifest.name)).toEqual(platformPackages.map(([, name]) => name)); + expect(manifests.every(manifest => manifest.private !== true)).toBe(true); + expect(manifests.every(manifest => manifest.peerDependencies?.['@tokenroll/acplugin'] === 'workspace:^')).toBe(true); + expect(manifests.every(manifest => manifest.dependencies?.['@acplugin/core'] === undefined)).toBe(true); + }); + + it('keeps exactly nine public packages with no private runtime dependency', async () => { + /** 主包、六个 Platform 和两个 Extension 的公开清单。 */ + const manifests = await Promise.all(publicPackageFiles.map(async file => readJson(file))); + /** 九个公开包的预期正式名称。 */ + const expectedNames = [ + '@tokenroll/acplugin', + ...platformPackages.map(([, name]) => name), + '@tokenroll/acplugin-extension-hooks', + '@tokenroll/acplugin-extension-mcp', + ]; + + expect(manifests.map(manifest => manifest.name)).toEqual(expectedNames); + expect(manifests.every(manifest => manifest.private !== true)).toBe(true); + for (const manifest of manifests) { + /** 公开运行时依赖中可能泄漏的私有包名。 */ + const privateRuntimeDependencies = Object.keys(manifest.dependencies ?? {}).filter(name => name.startsWith('@acplugin/')); + expect(privateRuntimeDependencies).toEqual([]); + } + for (const manifest of manifests.slice(1)) + expect(manifest.peerDependencies?.['@tokenroll/acplugin']).toBe('workspace:^'); + }); + + it('uses nested pnpm workspace patterns and TypeScript 7 for source packages', async () => { + /** pnpm workspace 与 catalog 配置原文。 */ + const workspace = await fs.readFile(path.join(root, 'pnpm-workspace.yaml'), 'utf8'); + /** 所有生态源码包的 package.json 路径。 */ + const sourcePackageFiles = [ + ...platformPackages.map(([directory]) => `packages/platforms/${directory}/package.json`), + 'packages/extensions/hooks/package.json', + 'packages/extensions/mcp/package.json', + ]; + /** 新源码包实际读取到的清单。 */ + const manifests = await Promise.all(sourcePackageFiles.map(async file => readJson(file))); + + expect(workspace).toContain('- packages/*'); + expect(workspace).toContain('- packages/platforms/*'); + expect(workspace).toContain('- packages/extensions/*'); + expect(workspace).toContain('\'@typescript/native\': npm:typescript@^7.0.2'); + expect(manifests.every(manifest => manifest.devDependencies?.['@typescript/native'] === 'catalog:')).toBe(true); + }); +}); diff --git a/packages/test/test/architecture/workspace.test.ts b/packages/test/test/architecture/workspace.test.ts new file mode 100644 index 0000000..4066c52 --- /dev/null +++ b/packages/test/test/architecture/workspace.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'vitest'; +import { LIFECYCLE_API_VERSION } from '@acplugin/core'; + +describe('workspace', () => { + it('resolves private production packages from the test workspace', () => { + expect(LIFECYCLE_API_VERSION).toBe('1'); + }); +}); diff --git a/packages/test/test/cli/build.test.ts b/packages/test/test/cli/build.test.ts new file mode 100644 index 0000000..93d5c00 --- /dev/null +++ b/packages/test/test/cli/build.test.ts @@ -0,0 +1,202 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { runProject, serializeBuildReport } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; +import cursor from '@tokenroll/acplugin-platform-cursor'; + +/** 临时配置与当前 Vitest 源码图共享官方 Platform 工厂的隔离全局键。 */ +const BUILD_TEST_PLATFORMS = Symbol.for('tokenroll.acplugin.build-test-platforms'); + +Reflect.set(globalThis, BUILD_TEST_PLATFORMS, Object.freeze({ claudeCode, codex })); + +/** 当前测试创建并在 afterEach 中统一删除的临时工程根目录。 */ +const roots: string[] = []; + +/** 完整受管输出树中的一个稳定文件快照。 */ +interface OutputFileSnapshot { + /** 使用 POSIX 分隔符的 dist 相对路径。 */ + readonly path: string; + /** 只保留 Asset 契约关心的权限位。 */ + readonly mode: number; + /** 未文本化的真实文件字节。 */ + readonly bytes: Buffer; +} + +/** + * 递归读取完整 dist 文件树,供跨绝对根执行字节级比较。 + * + * @param directory 当前遍历目录。 + * @param outputRoot 受管输出根。 + * @returns 按 code-unit 路径排序的普通文件快照。 + */ +async function outputTree(directory: string, outputRoot: string = directory): Promise { + /** 当前目录按 code-unit 排序后的文件系统项。 */ + const entries = (await fs.readdir(directory, { withFileTypes: true })) + .sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0); + /** 当前子树累计的普通文件快照。 */ + const files: OutputFileSnapshot[] = []; + for (const entry of entries) { + /** 当前目录项的绝对路径。 */ + const target = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...await outputTree(target, outputRoot)); + } else if (entry.isFile()) { + /** 当前输出文件的权限与真实字节。 */ + const stat = await fs.stat(target); + files.push({ + path: path.relative(outputRoot, target).split(path.sep).join('/'), + mode: stat.mode & 0o777, + bytes: await fs.readFile(target), + }); + } + } + return files; +} + +/** + * 创建包含最小 Skill 和可选自定义配置的测试工程。 + * + * @param config 可选的完整配置源码。 + * @returns 自动登记清理的工程绝对路径。 + */ +async function project(config = ''): Promise { + /** 当前集成测试独占并自动登记清理的工程根。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-build-test-')); + roots.push(root); + await fs.mkdir(path.join(root, 'src/skills/hello'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/skills/hello/SKILL.md'), '---\ndescription: Say hello.\n---\nSay hello to the user.\n'); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), config || `const platforms = globalThis[Symbol.for('tokenroll.acplugin.build-test-platforms')]; +export default { + name: 'hello-plugin', + version: '1.0.0', + description: 'Hello plugin.', + platforms: [platforms.claudeCode(), platforms.codex()], + }`); + return root; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('unified pipeline', () => { + it('validates without committing and builds both default Platforms atomically', async () => { + /** 默认双 Platform 构建使用的最小工程。 */ + const root = await project(); + /** 不提交任何输出的 validate 结果。 */ + const validate = await runProject({ cwd: root, command: 'validate', mode: 'production', commit: false }); + + expect(validate.success).toBe(true); + expect(validate.committed).toBe(false); + await expect(fs.access(path.join(root, 'dist'))).rejects.toThrow(); + + /** 原子提交 Claude Code 与 Codex 输出的 build 结果。 */ + const build = await runProject({ cwd: root, command: 'build', mode: 'production', commit: true }); + expect(build.success).toBe(true); + expect(build.committed).toBe(true); + expect(JSON.parse(await fs.readFile(path.join(root, 'dist/claude-code/plugin/.claude-plugin/plugin.json'), 'utf8'))).toMatchObject({ name: 'hello-plugin' }); + expect(JSON.parse(await fs.readFile(path.join(root, 'dist/codex/plugin/.codex-plugin/plugin.json'), 'utf8'))).toMatchObject({ skills: './skills/' }); + + /** build 命令显式关闭 commit 时仍完整物化验证,但不创建新 outDir。 */ + await fs.rm(path.join(root, 'dist'), { recursive: true, force: true }); + /** 关闭提交后的完整 build 报告。 */ + const dryBuild = await runProject({ cwd: root, command: 'build', mode: 'production', commit: false }); + expect(dryBuild).toMatchObject({ success: true, committed: false, command: 'build' }); + await expect(fs.access(path.join(root, 'dist'))).rejects.toThrow(); + }); + + it('keeps the complete dist tree, Asset hashes, and report bytes stable across roots and unrelated environment values', async () => { + /** 相同字节工程使用的两个不同绝对根。 */ + const firstRoot = await project(); + /** 与第一个工程字节相同但绝对位置不同的第二个根。 */ + const secondRoot = await project(); + /** 测试结束后需要恢复的原始环境值。 */ + const previousEnvironment = process.env.ACPLUGIN_UNRELATED_FIXTURE; + try { + process.env.ACPLUGIN_UNRELATED_FIXTURE = 'first-machine-value'; + /** 第一个根和环境输入下的内置构建报告。 */ + const first = await runProject({ cwd: firstRoot, command: 'build', mode: 'production' }); + process.env.ACPLUGIN_UNRELATED_FIXTURE = 'second-machine-value'; + /** 第二个根和无关环境输入下的内置构建报告。 */ + const second = await runProject({ cwd: secondRoot, command: 'build', mode: 'production' }); + + expect(second.packages).toEqual(first.packages); + expect(serializeBuildReport(second)).toBe(serializeBuildReport(first)); + expect(await outputTree(path.join(secondRoot, 'dist'))).toEqual(await outputTree(path.join(firstRoot, 'dist'))); + } finally { + if (previousEnvironment === undefined) + delete process.env.ACPLUGIN_UNRELATED_FIXTURE; + else + process.env.ACPLUGIN_UNRELATED_FIXTURE = previousEnvironment; + } + }); + + it('preserves the last complete dual-Platform output when either Platform fails', async () => { + /** 先生成一份可用于失败回滚对比的完整默认输出。 */ + const root = await project(); + /** 产生回滚基线的首次双 Platform 构建。 */ + const first = await runProject({ cwd: root, command: 'build', mode: 'production' }); + expect(first).toMatchObject({ success: true, committed: true }); + /** Claude Code 原生但 Codex 会降级的 Agent,使默认严格构建整体失败。 */ + await fs.mkdir(path.join(root, 'src/agents'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/agents/reviewer.md'), '---\ndescription: Review changes.\n---\nReview changes carefully.\n'); + /** 失败前 Codex Plugin Manifest 的稳定内容。 */ + const manifest = path.join(root, 'dist/codex/plugin/.codex-plugin/plugin.json'); + /** 用于确认失败事务未覆盖旧产物的基线文本。 */ + const previous = await fs.readFile(manifest, 'utf8'); + + /** 新增不兼容 Agent 后的预期失败构建。 */ + const failed = await runProject({ cwd: root, command: 'build', mode: 'production' }); + + expect(failed).toMatchObject({ success: false, committed: false }); + expect(failed.diagnostics).toContainEqual(expect.objectContaining({ code: 'COMPATIBILITY_STRICT_FAILURE', platform: codex().id })); + expect(await fs.readFile(manifest, 'utf8')).toBe(previous); + }); + + it('fails strict Codex compatibility for Agents and succeeds when relaxed', async () => { + /** 包含 Codex 降级 Agent 的测试工程。 */ + const root = await project(); + await fs.mkdir(path.join(root, 'src/agents'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/agents/reviewer.md'), '---\ndescription: Review changes.\n---\nReview changes carefully.\n'); + + /** 严格模式下预期失败的 Codex 验证结果。 */ + const strict = await runProject({ cwd: root, command: 'validate', mode: 'production', platforms: [codex({ strict: true }).id] }); + expect(strict.success).toBe(false); + expect(strict.diagnostics).toContainEqual(expect.objectContaining({ code: 'COMPATIBILITY_STRICT_FAILURE' })); + + /** 宽松模式下保留降级结论但成功的 Codex 验证结果。 */ + await fs.writeFile(path.join(root, 'acplugin.config.ts'), `const platforms = globalThis[Symbol.for('tokenroll.acplugin.build-test-platforms')]; +export default { name: 'hello-plugin', version: '1.0.0', description: 'Hello plugin.', platforms: [platforms.claudeCode(), platforms.codex({ strict: false })] };`); + /** 使用新配置重新解析宽松 Codex Platform。 */ + const relaxed = await runProject({ cwd: root, command: 'validate', mode: 'production', platforms: [codex({ strict: false }).id] }); + expect(relaxed.success, JSON.stringify(relaxed.diagnostics)).toBe(true); + expect(relaxed.compatibility).toContainEqual(expect.objectContaining({ subject: 'agent:reviewer', level: 'degraded' })); + }); + + it('rejects empty, duplicate, and unconfigured Platform selections before execution', async () => { + /** Platform 子集边界测试使用的最小工程。 */ + const root = await project(); + /** 三种非法选择对应的稳定诊断码。 */ + const cases: readonly { platforms: readonly string[] }[] = [ + { platforms: [] as const }, + { platforms: [codex().id, codex().id] }, + { platforms: [cursor().id] }, + ]; + + /** item 表示当前待验证的非法 Platform 子集。 */ + for (const item of cases) { + await expect(runProject({ + cwd: root, + command: 'validate', + mode: 'production', + platforms: item.platforms, + })).resolves.toMatchObject({ + success: false, + diagnostics: [expect.objectContaining({ code: 'PLATFORM_SELECTION_INVALID' })], + }); + } + }); +}); diff --git a/packages/test/test/cli/cli.test.ts b/packages/test/test/cli/cli.test.ts new file mode 100644 index 0000000..91facb7 --- /dev/null +++ b/packages/test/test/cli/cli.test.ts @@ -0,0 +1,961 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +/** 构建后 CLI 入口的绝对路径,用于真实子进程契约测试。 */ +const cli = path.resolve(import.meta.dirname, '../../../acplugin/dist/cli.mjs'); +/** CLI 子进程配置显式加载的两个独立 Platform 构建入口。 */ +const claudeCodeEntry = path.resolve(import.meta.dirname, '../../../platforms/claude-code/dist/index.mjs'); +/** CLI 子进程配置加载的 Codex Platform 构建入口。 */ +const codexEntry = path.resolve(import.meta.dirname, '../../../platforms/codex/dist/index.mjs'); +/** CLI 子进程配置与官方 Integration 共用的主包 SDK 构建入口。 */ +const acpluginEntry = path.resolve(import.meta.dirname, '../../../acplugin/dist/index.mjs'); +/** 所有有效 CLI fixture 共用的独立 Platform 导入源码。 */ +const platformImports = `import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex';`; +/** 所有有效 CLI fixture 共用的显式 Platform 字段。 */ +const platformField = 'platforms: [claudeCode({ strict: false }), codex({ strict: false })],'; +/** 当前测试创建并在 afterEach 中统一删除的临时工程目录。 */ +const roots: string[] = []; +/** 测试共用的子进程清理与临时工程登记状态。 */ +/** 尚未退出的 CLI 子进程,失败清理时会被强制终止。 */ +const children = new Set(); + +/** 正在运行的 CLI 子进程及其增量输出读取接口。 */ +interface RunningCli { + /** 可写 stdin、可监听退出事件的真实 Node 子进程。 */ + child: ChildProcessWithoutNullStreams; + /** @returns 当前累计 stdout。 */ + stdout(): string; + /** @returns 当前累计 stderr。 */ + stderr(): string; +} + +/** + * 创建当前 CLI 测试独占的临时工程目录。 + * + * @returns 自动登记清理的绝对路径。 + */ +async function temporaryProject(): Promise { + /** 当前 CLI 子进程测试独占且会统一清理的工程根。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-cli-test-')); + roots.push(root); + await writePackageProxy(root, '@tokenroll/acplugin', acpluginEntry, { './sdk': './sdk.mjs' }); + await writePackageProxy(root, '@tokenroll/acplugin-platform-claude-code', claudeCodeEntry); + await writePackageProxy(root, '@tokenroll/acplugin-platform-codex', codexEntry); + return root; +} + +/** 在临时工程中建立官方 Platform 的真实构建包代理。 */ +async function writePackageProxy(root: string, packageName: string, entry: string, extraExports: Record = {}): Promise { + /** 临时 consumer 中对应包名的物理目录。 */ + const packageRoot = path.join(root, 'node_modules', ...packageName.split('/')); + await fs.mkdir(packageRoot, { recursive: true }); + /** 已构建包的 dist 目录。 */ + const sourceRoot = path.dirname(entry); + /** 需复制的所有 ESM chunk 文件。 */ + const files = await fs.readdir(sourceRoot); + await Promise.all(files.filter(file => file.endsWith('.mjs')).map(file => fs.copyFile(path.join(sourceRoot, file), path.join(packageRoot, file)))); + /** 包代理保留根入口与所需子路径。 */ + const exports = Object.keys(extraExports).length === 0 ? './index.mjs' : { '.': './index.mjs', ...extraExports }; + await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ name: packageName, version: '1.0.0', type: 'module', exports })); + await fs.copyFile(entry, path.join(packageRoot, 'index.mjs')); + /** 官方构建包的外部依赖通过其 Workspace package-manager symlink 进入临时 consumer。 */ + await fs.symlink(path.resolve(sourceRoot, '..', 'node_modules'), path.join(packageRoot, 'node_modules'), 'dir').catch(() => undefined); + if (extraExports['./sdk'] !== undefined) + await fs.copyFile(path.join(sourceRoot, 'sdk.mjs'), path.join(packageRoot, 'sdk.mjs')); +} + +/** + * 启动真实 CLI 子进程并持续捕获 stdout/stderr。 + * + * @param args 传给 CLI 的参数。 + * @param cwd 子进程工作目录。 + * @returns 可等待、终止和读取增量输出的运行记录。 + */ +function startCli(args: readonly string[], cwd: string): RunningCli { + /** 继承环境但关闭颜色的 CLI 子进程。 */ + const child = spawn(process.execPath, [cli, ...args], { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, NO_COLOR: '1' }, + }); + children.add(child); + /** 当前累计标准输出。 */ + let stdout = ''; + /** 当前累计标准错误。 */ + let stderr = ''; + child.stdout.setEncoding('utf8').on('data', chunk => stdout += chunk); + child.stderr.setEncoding('utf8').on('data', chunk => stderr += chunk); + child.once('close', () => children.delete(child)); + return { + child, + /** stdout 返回当前累计的标准输出。 */ + stdout: () => stdout, + /** stderr 返回当前累计的标准错误。 */ + stderr: () => stderr, + }; +} + +/** + * 等待 CLI 子进程退出并返回最终输出快照。 + * + * @param running startCli 返回的运行记录。 + * @returns 退出码和完整 stdout/stderr。 + */ +async function waitForExit(running: RunningCli): Promise<{ code: number | null; stdout: string; stderr: string }> { + /** close 事件提供的进程退出码。 */ + const code = await new Promise((resolve, reject) => { + running.child.once('error', reject); + running.child.once('close', resolve); + }); + return { code, stdout: running.stdout(), stderr: running.stderr() }; +} + +/** + * 执行一个不需要持续 stdin 的 CLI 命令并等待退出。 + * + * @param args 传给 CLI 的参数。 + * @param cwd 子进程工作目录。 + * @returns 退出码和完整输出。 + */ +async function runCli(args: readonly string[], cwd: string): Promise<{ code: number | null; stdout: string; stderr: string }> { + /** 当前一次性 CLI 命令的运行记录。 */ + const running = startCli(args, cwd); + running.child.stdin.end(); + return waitForExit(running); +} + +/** + * 等待持续运行 CLI 的输出满足断言条件,并带超时和提前退出诊断。 + * + * @param running 正在运行的 CLI。 + * @param predicate 判断累计输出是否已满足条件的函数。 + * @param description 超时错误使用的等待目标描述。 + */ +async function waitForOutput( + running: RunningCli, + predicate: (stdout: string, stderr: string) => boolean, + description: string, +): Promise { + if (predicate(running.stdout(), running.stderr())) + return; + await new Promise((resolve, reject) => { + /** 防止 dev 子进程异常挂起测试的超时器。 */ + const timeout = setTimeout(() => finish(new Error(`Timed out waiting for ${description}.\nstdout:\n${running.stdout()}\nstderr:\n${running.stderr()}`)), 10_000); + /** 每次收到输出时重新检查等待条件。 */ + const check = (): void => { + if (predicate(running.stdout(), running.stderr())) + finish(); + }; + /** CLI 提前退出时生成带退出码的等待失败。 */ + const closed = (code: number | null): void => finish(new Error(`CLI exited with ${code} while waiting for ${description}.\nstdout:\n${running.stdout()}\nstderr:\n${running.stderr()}`)); + /** 清理所有监听器并只完成一次 Promise。 */ + const finish = (error?: Error): void => { + clearTimeout(timeout); + running.child.stdout.off('data', check); + running.child.stderr.off('data', check); + running.child.off('close', closed); + if (error) + reject(error); + else + resolve(); + }; + running.child.stdout.on('data', check); + running.child.stderr.on('data', check); + running.child.once('close', closed); + }); +} + +/** 等待持续构建最终 Asset 达到预期内容。 */ +async function waitForFileContent(file: string, content: string): Promise { + /** 文件事务交换允许的有限等待截止点。 */ + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + if ((await fs.readFile(file, 'utf8').catch(() => '')).includes(content)) + return; + await new Promise(resolve => setTimeout(resolve, 20)); + } + throw new Error(`Timed out waiting for ${path.basename(file)} content; current=${await fs.readFile(file, 'utf8').catch(() => '')}`); +} + +/** + * 写入可供 validate/inspect/build/dev 共同使用的最小规范工程。 + * + * @param root 测试工程根目录。 + */ +async function writeValidProject(root: string): Promise { + await fs.mkdir(path.join(root, 'src/skills/hello'), { recursive: true }); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), `${platformImports} +export default { + name: 'cli-plugin', + version: '1.0.0', + description: 'CLI fixture.', + ${platformField} +};\n`); + await fs.writeFile(path.join(root, 'src/skills/hello/SKILL.md'), `--- +description: Say hello. +--- +Say hello. +`); +} + +afterEach(async () => { + for (const child of children) + child.kill('SIGKILL'); + children.clear(); + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe.sequential('CLI subprocess contract', () => { + it('prints Help without prompting and classifies usage errors as exit 2', async () => { + /** 帮助与参数错误测试使用的空工程根。 */ + const root = await temporaryProject(); + /** 不传子命令时 CLI 返回的帮助输出。 */ + const help = await runCli([], root); + expect(help).toMatchObject({ code: 0, stderr: '' }); + expect(help.stdout).toContain('Usage: acplugin'); + expect(help.stdout).not.toContain('?'); + + /** 旧 --target 参数必须给出迁移到 --platform 的定向用法错误。 */ + const usage = await runCli(['build', '--target', 'unknown'], root); + expect(usage.code).toBe(2); + expect(usage.stderr).toContain('--target'); + expect(usage.stderr).toContain('--platform'); + + /** option 终止符后的同名文本不得触发旧参数专属错误。 */ + const terminated = await runCli(['build', '--', '--target'], root); + expect(terminated.stderr).not.toContain('has been removed'); + }); + + it('surfaces safe init validation reasons without exposing the internal error type', async () => { + /** 非空目标用于触发已知且可操作的初始化输入错误。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'occupied')); + await fs.writeFile(path.join(root, 'occupied/keep.txt'), 'keep'); + /** JSON 模式应保留安全原因而不是通用命令失败文本。 */ + const result = await runCli(['init', 'occupied', '--yes', '--json'], root); + /** CLI 返回的稳定失败报告。 */ + const report = JSON.parse(result.stdout); + + expect(result.code).toBe(1); + expect(result.stderr).toBe(''); + expect(report).toMatchObject({ + command: 'init', + success: false, + diagnostics: [{ code: 'INIT_INVALID', message: 'destination directory is not empty', phase: 'init' }], + }); + }); + + it('emits one JSON document and exit 1 for project configuration errors', async () => { + /** 缺失配置入口的临时工程根。 */ + const root = await temporaryProject(); + /** JSON 模式下配置加载失败的完整 CLI 结果。 */ + const result = await runCli(['validate', '--json'], root); + + expect(result.code).toBe(1); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toMatchObject({ + schemaVersion: 2, + success: false, + diagnostics: [{ code: 'CONFIG_LOAD_FAILED', severity: 'error', phase: 'config' }], + }); + }); + + it('shares the pipeline while only build commits output', async () => { + /** 四个命令共享 Pipeline 的规范测试工程。 */ + const root = await temporaryProject(); + await writeValidProject(root); + + /** 只验证且不落盘的 validate 子进程结果。 */ + const validate = await runCli(['validate', '--json'], root); + expect(validate.code).toBe(0); + expect(JSON.parse(validate.stdout)).toMatchObject({ + command: 'validate', + success: true, + committed: false, + platforms: [{ id: 'claude-code' }, { id: 'codex' }], + }); + await expect(fs.access(path.join(root, 'dist'))).rejects.toThrow(); + + /** 返回 Asset 摘要但不落盘的 inspect 子进程结果。 */ + const inspect = await runCli(['inspect', '--json'], root); + expect(inspect.code).toBe(0); + /** inspect 必须额外包含七类可审计对象中的结构化详情。 */ + const inspected = JSON.parse(inspect.stdout); + expect(inspected).toMatchObject({ + components: [{ kind: 'skill', id: 'hello' }], + extensions: [], + }); + expect(inspected.packages.flatMap((unit: { assets: unknown[] }) => unit.assets).length).toBeGreaterThan(0); + await expect(fs.access(path.join(root, 'dist'))).rejects.toThrow(); + + /** 唯一应提交 dist 输出的 build 子进程结果。 */ + const build = await runCli(['build', '--json'], root); + expect(build.code).toBe(0); + expect(JSON.parse(build.stdout)).toMatchObject({ success: true, committed: true }); + await fs.access(path.join(root, 'dist/codex/plugin/.codex-plugin/plugin.json')); + + /** --platform 只选择已配置子集,并在成功事务中替换先前完整输出。 */ + const selected = await runCli(['build', '--platform', 'codex', '--json'], root); + expect(selected.code).toBe(0); + expect(JSON.parse(selected.stdout)).toMatchObject({ platforms: [{ id: 'claude-code', selected: false }, { id: 'codex', selected: true, success: true }], committed: true }); + await fs.access(path.join(root, 'dist/codex/plugin/.codex-plugin/plugin.json')); + await expect(fs.access(path.join(root, 'dist/claude-code'))).resolves.toBeUndefined(); + + /** 未配置 Platform 由统一配置边界拒绝,而不是按 ID 临时实例化。 */ + const unconfigured = await runCli(['validate', '--platform', 'cursor', '--json'], root); + expect(unconfigured.code).toBe(1); + expect(JSON.parse(unconfigured.stdout)).toMatchObject({ + success: false, + diagnostics: [{ code: 'PLATFORM_SELECTION_INVALID' }], + }); + }); + + it('lazy-loads bundled Migration and validates generated projects through the public pipeline', async () => { + /** CLI 动态 Migration smoke 使用的临时工作目录。 */ + const root = await temporaryProject(); + /** 包含规范资源、远程 MCP 与未映射内容的固定 Legacy Fixture。 */ + const source = path.resolve(import.meta.dirname, '../../fixtures/migration/claude-project'); + /** dry-run 不会创建、但仍必须满足目标边界检查的候选路径。 */ + const destination = path.join(root, 'migrated'); + + /** 真实 CLI 必须能加载独立 Migration chunk 及其正式验证 Profile。 */ + const execution = await runCli([ + 'migrate', + source, + destination, + '--name', + 'cli-migration', + '--description', + 'CLI Migration fixture.', + '--dry-run', + '--json', + ], root); + expect(execution.code).toBe(0); + expect(JSON.parse(execution.stdout)).toMatchObject({ + schemaVersion: '1', + success: true, + dryRun: true, + projects: ['.'], + }); + await expect(fs.access(destination)).rejects.toThrow(); + }); + + // watch 契约需要等待三次独立构建事件;为测试本身保留足够时间,避免外层默认超时先于状态诊断触发。 + it('retains the last successful dev output, recovers, and exits 130 on SIGINT', async () => { + /** dev 增量重建测试使用的规范工程根。 */ + const root = await temporaryProject(); + await writeValidProject(root); + /** 用于触发失败与恢复重建的 Skill 源文件。 */ + const skill = path.join(root, 'src/skills/hello/SKILL.md'); + /** dev 应持续保留最近成功版本的生成文件。 */ + const generated = path.join(root, 'dist/codex/plugin/skills/hello/SKILL.md'); + /** 持续运行并监听文件变化的真实 dev 子进程。 */ + const running = startCli(['dev'], root); + + await waitForOutput(running, stdout => stdout.includes('dev: success'), 'initial dev build'); + /** 首次成功构建后的生成内容快照。 */ + const initial = await fs.readFile(generated, 'utf8'); + + await fs.writeFile(skill, 'invalid without frontmatter\n'); + await waitForOutput(running, (_stdout, stderr) => stderr.includes('FRONTMATTER_REQUIRED'), 'failed rebuild diagnostic'); + expect(await fs.readFile(generated, 'utf8')).toBe(initial); + + await fs.writeFile(skill, `--- +description: Say hello again. +--- +Say hello after recovery. +`); + await waitForOutput(running, stdout => stdout.match(/dev: success/g)?.length === 2, 'recovery build'); + expect(await fs.readFile(generated, 'utf8')).toContain('Say hello after recovery.'); + + running.child.kill('SIGINT'); + /** SIGINT 后进程的最终退出状态与输出。 */ + const stopped = await waitForExit(running); + expect(stopped.code).toBe(130); + expect((await fs.readdir(root)).filter(name => name.startsWith('.acplugin-work-'))).toEqual([]); + expect((await fs.readdir(root)).filter(name => name.includes('.acplugin.lock'))).toEqual([]); + }, 20_000); + + it('watches the project-local TypeScript config closure and recovers after failure', async () => { + /** 容纳独立项目和本地配置 helper 的临时 workspace。 */ + const workspace = await temporaryProject(); + /** dev 子进程使用的独立项目根。 */ + const root = path.join(workspace, 'plugin'); + /** Core Module Service 随配置入口 Bundle 并监听的本地 helper。 */ + const helperRoot = path.join(root, 'config'); + /** 修改后应触发配置重新执行的 TypeScript 文件。 */ + const helper = path.join(helperRoot, 'value.ts'); + await fs.mkdir(path.join(root, 'src/skills/hello'), { recursive: true }); + await fs.mkdir(helperRoot, { recursive: true }); + await fs.writeFile(helper, `export const description = 'First local config.';\n`); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), `${platformImports} +import { description } from './config/value.ts'; +export default { name: 'local-config-plugin', version: '1.0.0', description, ${platformField} }; +`); + await fs.writeFile(path.join(root, 'src/skills/hello/SKILL.md'), `--- +description: Verify external config watching. +--- +Watch the external helper. +`); + /** 持续监听完整本地配置闭包的真实 dev 子进程。 */ + const running = startCli(['dev'], root); + /** 构建输出中直接反映配置 description 的 Claude Manifest。 */ + const manifestPath = path.join(root, 'dist/claude-code/plugin/.claude-plugin/plugin.json'); + + await waitForOutput(running, stdout => stdout.includes('dev: success'), 'local config initial build'); + /** 首次成功提交的 Manifest,配置失败期间必须保持不变。 */ + const initialManifest = await fs.readFile(manifestPath, 'utf8'); + expect(JSON.parse(initialManifest)).toMatchObject({ description: 'First local config.' }); + + await fs.writeFile(helper, 'export const description = ;\n'); + await waitForOutput(running, (_stdout, stderr) => stderr.includes('CONFIG_EVALUATION_FAILED'), 'local config failed rebuild'); + expect(await fs.readFile(manifestPath, 'utf8')).toBe(initialManifest); + + await fs.writeFile(helper, `export const description = 'Second local config.';\n`); + await waitForOutput(running, stdout => stdout.match(/dev: success/g)?.length === 2, 'local config recovery build'); + expect(JSON.parse(await fs.readFile(manifestPath, 'utf8'))).toMatchObject({ description: 'Second local config.' }); + + running.child.kill('SIGINT'); + /** 本地配置依赖恢复后的信号退出状态。 */ + const stopped = await waitForExit(running); + expect(stopped.code).toBe(130); + }, 20_000); + + it('rebuilds after initial watcher readiness before publishing the first success', async () => { + /** 初始 ready 竞态测试使用的规范工程根。 */ + const root = await temporaryProject(); + await writeValidProject(root); + /** 首次 buildEnd 等待测试进程完成源码修改的显式同步文件。 */ + const release = path.join(root, 'release-initial-build'); + await fs.mkdir(path.join(root, 'src/initial-ready-barrier'), { recursive: true }); + /** ready 窗口内修改且最终产物必须包含新正文的 Skill。 */ + const skill = path.join(root, 'src/skills/hello/SKILL.md'); + /** 构造同步 Extension 时与 CLI Bundle 共享品牌 Symbol 的已构建 Facade。 */ + await fs.writeFile(path.join(root, 'acplugin.config.ts'), `${platformImports} +import { promises as fs } from 'node:fs'; +import { defineExtension } from '@tokenroll/acplugin/sdk'; +const barrier = defineExtension({ + id: 'initial-ready-barrier', + apiVersion: '1', + resourceRoots: ['initial-ready-barrier'], + createSession: () => ({ + discover: () => ({}), + validate: () => ({ state: {}, subjects: [] }), + async build() { + process.stderr.write('fixture: initial snapshot complete\\n'); + while (true) { + try { await fs.access(${JSON.stringify(release)}); break; } + catch { await new Promise(resolve => setTimeout(resolve, 10)); } + } + return { state: {} }; + }, + contributors: [{ platform: 'codex', platformApiVersion: '1', contribute: () => ({ compatibility: [] }) }], + }), +}); +export default { + ${platformField} + name: 'cli-plugin', + version: '1.0.0', + description: 'Initial ready fixture.', + extensions: [barrier], + build: { strict: false }, +}; +`); + /** 首次成功提示必须等到补偿构建完成的真实 dev 子进程。 */ + const running = startCli(['dev'], root); + + await waitForOutput(running, (_stdout, stderr) => stderr.includes('fixture: initial snapshot complete'), 'initial snapshot barrier'); + await fs.writeFile(skill, `--- +description: Changed before watcher readiness. +--- +Catch-up source content. +`); + await fs.writeFile(release, 'continue\n'); + await waitForOutput(running, stdout => stdout.includes('dev: success'), 'catch-up initial dev build'); + /** 首个可公开成功应已包含 ready 窗口内的修改。 */ + const initialGenerated = path.join(root, 'dist/codex/plugin/skills/hello/SKILL.md'); + await waitForFileContent(initialGenerated, 'Catch-up source content.'); + expect(await fs.readFile(initialGenerated, 'utf8')).toContain('Catch-up source content.'); + + running.child.kill('SIGINT'); + expect((await waitForExit(running)).code).toBe(130); + }, 20_000); + + // 配置恢复首次发现的工程根必须完成动态 ready,成功提示才能成为后续修改不会丢失的同步边界。 + it('waits for dynamically discovered paths before reporting a recovered dev build', async () => { + /** 初始缺失配置、但已经包含合法规范资源的临时工程根。 */ + const root = await temporaryProject(); + /** 配置恢复后立即修改、用于验证动态监听就绪边界的 Skill。 */ + const skill = path.join(root, 'src/skills/hello/SKILL.md'); + /** 恢复构建在登记动态工程根前使用的显式同步文件。 */ + const release = path.join(root, 'release-recovered-build'); + await fs.mkdir(path.join(root, 'src/dynamic-ready-barrier'), { recursive: true }); + await fs.mkdir(path.dirname(skill), { recursive: true }); + await fs.writeFile(skill, `--- +description: Say hello after configuration recovery. +--- +First recovered build. +`); + /** 只监听尚不存在配置入口的真实 dev 子进程。 */ + const running = startCli(['dev'], root); + + await waitForOutput(running, (_stdout, stderr) => stderr.includes('CONFIG_LOAD_FAILED'), 'initial missing configuration failure'); + /** 构造恢复同步 Extension 时与 CLI Bundle 共享品牌 Symbol 的已构建 Facade。 */ + await fs.writeFile(path.join(root, 'acplugin.config.ts'), `${platformImports} +import { promises as fs } from 'node:fs'; +import { defineExtension } from '@tokenroll/acplugin/sdk'; +const barrier = defineExtension({ + id: 'dynamic-ready-barrier', + apiVersion: '1', + resourceRoots: ['dynamic-ready-barrier'], + createSession: () => ({ + discover: () => ({}), + validate: () => ({ state: {}, subjects: [] }), + async build() { + process.stderr.write('fixture: recovered snapshot complete\\n'); + while (true) { + try { await fs.access(${JSON.stringify(release)}); break; } + catch { await new Promise(resolve => setTimeout(resolve, 10)); } + } + return { state: {} }; + }, + contributors: [{ platform: 'codex', platformApiVersion: '1', contribute: () => ({ compatibility: [] }) }], + }), +}); +export default { + ${platformField} + name: 'recovered-plugin', + version: '1.0.0', + description: 'Recovered CLI fixture.', + extensions: [barrier], + build: { strict: false }, +}; +`); + await waitForOutput(running, (_stdout, stderr) => stderr.includes('fixture: recovered snapshot complete'), 'recovered snapshot barrier'); + await fs.writeFile(skill, `--- +description: Say hello after dynamic watcher readiness. +--- +Second recovered build. +`); + await fs.writeFile(release, 'continue\n'); + await waitForOutput(running, stdout => stdout.includes('dev: success'), 'recovered catch-up build'); + /** 首次公开成功已经包含动态 ready 窗口内发生的修改。 */ + const generated = path.join(root, 'dist/codex/plugin/skills/hello/SKILL.md'); + await waitForFileContent(generated, 'Second recovered build.'); + expect(await fs.readFile(generated, 'utf8')).toContain('Second recovered build.'); + + await fs.writeFile(skill, `--- +description: Say hello after active dynamic watching. +--- +Third watched build. +`); + await waitForOutput(running, stdout => stdout.match(/dev: success/g)?.length === 2, 'active dynamic path rebuild'); + await waitForFileContent(generated, 'Third watched build.'); + expect(await fs.readFile(generated, 'utf8')).toContain('Third watched build.'); + + running.child.kill('SIGINT'); + /** 动态监听回归场景结束后的信号退出状态。 */ + const stopped = await waitForExit(running); + expect(stopped.code).toBe(130); + }, 20_000); + + // signal handler 必须在首次 Pipeline 前安装,初始 discover 未完成时也要等待清理并稳定退出 130。 + it('drains an in-flight initial dev build when signalled before the first success', async () => { + /** 首次构建 signal 竞态测试使用的规范工程根。 */ + const root = await temporaryProject(); + await writeValidProject(root); + /** 首次 discover 延迟加载的模拟 Extension 包根。 */ + const extensionRoot = path.join(root, 'src/initial-stopping-extension'); + await fs.mkdir(extensionRoot, { recursive: true }); + await fs.writeFile(path.join(extensionRoot, 'package.json'), '{"name":"initial-stopping-extension","type":"module"}\n'); + /** 首次构建结束前加载、但 signal 后不得再登记监听的 descriptor。 */ + const descriptor = path.join(extensionRoot, 'descriptor.ts'); + await fs.writeFile(descriptor, `export default 'initial-stopping-extension';\n`); + /** 构造初始延迟 Extension 时与 CLI Bundle 共享品牌 Symbol 的已构建 Facade。 */ + const facade = '@tokenroll/acplugin/sdk'; + await fs.writeFile(path.join(root, 'acplugin.config.ts'), `${platformImports} +import { defineExtension } from '${facade}'; +process.stderr.write('fixture: initial dev build started\\n'); +const extension = defineExtension({ + id: 'initial-stopping-extension', + apiVersion: '1', + resourceRoots: ['initial-stopping-extension'], + createSession: () => ({ + async discover(context) { + await new Promise(resolve => setTimeout(resolve, 500)); + const root = await context.roots['initial-stopping-extension']; + await context.modules.loadDefault({ id: 'initial-stopping-extension', entry: await context.sources.file(root, 'descriptor.ts') }); + return undefined; + }, + validate: () => ({ state: {}, subjects: [] }), + build: () => ({ state: {} }), + contributors: [{ platform: 'codex', platformApiVersion: '1', contribute: () => ({ compatibility: [] }) }], + }), +}); +export default { + ${platformField} + name: 'cli-plugin', + version: '1.0.0', + description: 'Initial signal cleanup fixture.', + extensions: [extension], +}; +`); + /** 首次 success 前就会收到 SIGINT 的真实 dev 子进程。 */ + const running = startCli(['dev'], root); + + await waitForOutput(running, (_stdout, stderr) => stderr.includes('fixture: initial dev build started'), 'in-flight initial dev build'); + running.child.kill('SIGINT'); + /** 首次 Pipeline 必须完成清理后稳定返回 130,且不得发布 success。 */ + const stopped = await waitForExit(running); + expect(stopped.code).toBe(130); + expect(stopped.stdout).not.toContain('dev: success'); + }, 20_000); + + // signal 可能在动态重建发现新路径之前到达;退出必须等待 Pipeline,并禁止随后注册 watcher 或发布结果。 + it('drains an in-flight dynamic rebuild before closing all dev watchers', async () => { + /** signal 竞态测试使用的初始规范工程根。 */ + const root = await temporaryProject(); + await writeValidProject(root); + /** 本轮配置变更才会首次加载的模拟 Extension 包根。 */ + const extensionRoot = path.join(root, 'src/stopping-extension'); + /** 延迟 discover 结束时才会成为动态监听来源的 descriptor。 */ + const descriptor = path.join(extensionRoot, 'descriptor.ts'); + /** 持续运行并将在动态重建期间接收 SIGINT 的真实 dev 子进程。 */ + const running = startCli(['dev'], root); + + await waitForOutput(running, stdout => stdout.includes('dev: success'), 'initial signal fixture build'); + /** 资源根只能在配置声明 owner 的同一次编辑中出现。 */ + await fs.mkdir(extensionRoot, { recursive: true }); + await fs.writeFile(path.join(extensionRoot, 'package.json'), '{"name":"stopping-extension","type":"module"}\n'); + await fs.writeFile(descriptor, `export default 'stopping-extension';\n`); + /** 构造延迟 Extension 时与 CLI Bundle 共享品牌 Symbol 的已构建 Facade。 */ + const facade = '@tokenroll/acplugin/sdk'; + await fs.writeFile(path.join(root, 'acplugin.config.ts'), `${platformImports} +import { defineExtension } from '${facade}'; +process.stderr.write('fixture: dynamic rebuild started\\n'); +const extension = defineExtension({ + id: 'stopping-extension', + apiVersion: '1', + resourceRoots: ['stopping-extension'], + createSession: () => ({ + async discover(context) { + await new Promise(resolve => setTimeout(resolve, 500)); + const root = await context.roots['stopping-extension']; + await context.modules.loadDefault({ id: 'stopping-extension', entry: await context.sources.file(root, 'descriptor.ts') }); + return undefined; + }, + validate: () => ({ state: {}, subjects: [] }), + build: () => ({ state: {} }), + contributors: [], + }), +}); +export default { + ${platformField} + name: 'cli-plugin', + version: '1.0.0', + description: 'Signal cleanup fixture.', + extensions: [extension], +}; +`); + await waitForOutput(running, (_stdout, stderr) => stderr.includes('fixture: dynamic rebuild started'), 'in-flight dynamic rebuild'); + running.child.kill('SIGINT'); + /** signal 必须等待在途 Pipeline 收敛,并最终以 130 退出而不是被新 watcher 挂住。 */ + const stopped = await waitForExit(running); + expect(stopped.code).toBe(130); + expect(stopped.stdout.match(/dev: success/g)).toHaveLength(2); + }, 20_000); + + // Extension descriptor 的已解析依赖位于 node_modules 时,显式包根必须覆盖通用依赖忽略规则。 + it('rebuilds when a loaded Extension descriptor dependency changes', async () => { + /** descriptor 依赖监听测试使用的规范工程根。 */ + const root = await temporaryProject(); + await writeValidProject(root); + /** 模拟已安装 Extension 包的源码根。 */ + const extensionRoot = path.join(root, 'src/dev-extension'); + await fs.mkdir(extensionRoot, { recursive: true }); + await fs.writeFile(path.join(extensionRoot, 'package.json'), '{"name":"dev-extension","type":"module"}\n'); + /** descriptor 实际解析的同包依赖文件。 */ + const helper = path.join(extensionRoot, 'helper.ts'); + await fs.writeFile(helper, `export const value = 'first';\n`); + /** discover 通过共享加载器读取且会记录真实包根的 descriptor。 */ + const descriptor = path.join(extensionRoot, 'descriptor.ts'); + await fs.writeFile(descriptor, `import { value } from './helper.ts';\nexport default value;\n`); + /** 构造 Extension 时必须与 CLI Bundle 共享品牌 Symbol 的已构建 Facade。 */ + const facade = '@tokenroll/acplugin/sdk'; + await fs.writeFile(path.join(root, 'acplugin.config.ts'), `${platformImports} +import { defineExtension } from '${facade}'; +const extension = defineExtension({ + id: 'dev-extension', + apiVersion: '1', + resourceRoots: ['dev-extension'], + createSession: () => ({ + async discover(context) { + const root = await context.roots['dev-extension']; + await context.modules.loadDefault({ id: 'dev-extension', entry: await context.sources.file(root, 'descriptor.ts') }); + return undefined; + }, + validate: () => ({ state: {}, subjects: [] }), + build: () => ({ state: {} }), + contributors: [], + }), +}); +export default { + ${platformField} + name: 'cli-plugin', + version: '1.0.0', + description: 'CLI fixture.', + extensions: [extension], +}; +`); + /** 持续监听 Extension 包依赖的真实 dev 子进程。 */ + const running = startCli(['dev'], root); + + await waitForOutput(running, stdout => stdout.includes('dev: success'), 'descriptor dev build'); + await fs.writeFile(helper, `export const value = 'second';\n`); + await waitForOutput(running, stdout => stdout.match(/dev: success/g)?.length === 2, 'descriptor dependency rebuild'); + + running.child.kill('SIGINT'); + /** 依赖重建完成后正常响应信号的进程状态。 */ + const stopped = await waitForExit(running); + expect(stopped.code).toBe(130); + }, 20_000); + + it('watches dependency files registered by an Extension build graph', async () => { + /** build graph 监听测试使用的规范工程根。 */ + const root = await temporaryProject(); + await writeValidProject(root); + /** 位于默认 node_modules 忽略边界内、只能通过 addWatchFile 激活的依赖。 */ + const helper = path.join(root, 'node_modules/build-graph-helper/index.js'); + await fs.mkdir(path.dirname(helper), { recursive: true }); + await fs.writeFile(path.join(path.dirname(helper), 'package.json'), '{"name":"build-graph-helper","version":"1.0.0","type":"module","exports":"./index.js","license":"MIT"}\n'); + await fs.writeFile(path.join(path.dirname(helper), 'LICENSE'), 'Build graph fixture license.\n'); + await fs.writeFile(helper, 'export const value = "first";\n'); + /** Extension compiler 读取且登记依赖图的作者入口。 */ + const entry = path.join(root, 'src/build-graph-extension/entry.ts'); + await fs.mkdir(path.dirname(entry), { recursive: true }); + await fs.writeFile(entry, 'import { value } from "build-graph-helper"; export default value;\n'); + /** 构造测试 Extension 时与 CLI Bundle 共享品牌 Symbol 的已构建 Facade。 */ + const facade = '@tokenroll/acplugin/sdk'; + await fs.writeFile(path.join(root, 'acplugin.config.ts'), `${platformImports} +import { promises as fs } from 'node:fs'; +import { defineExtension } from '${facade}'; +const extension = defineExtension({ + id: 'build-graph-extension', + apiVersion: '1', + resourceRoots: ['build-graph-extension'], + createSession: () => ({ + async discover(context) { + const root = context.roots['build-graph-extension']; + return { entry: await context.sources.file(root, 'entry.ts') }; + }, + validate: (_context, discovered) => ({ state: discovered, subjects: [] }), + async build(context, validated) { + await context.compiler.compile({ id: 'build-graph-helper', profile: 'portable-node', entries: { main: { type: 'source', source: validated.entry } } }); + const value = (await fs.readFile(${JSON.stringify(helper)}, 'utf8')).trim().match(/"(.*?)"/)?.[1] ?? ''; + process.stderr.write('fixture: build graph ' + value + '\\n'); + return { state: value }; + }, + contributors: [{ platform: 'codex', platformApiVersion: '1', contribute: () => ({ compatibility: [] }) }], + }), +}); +export default { + ${platformField} + name: 'cli-plugin', + version: '1.0.0', + description: 'Build graph watch fixture.', + extensions: [extension], + build: { strict: false }, +}; +`); + /** 持续监听 Extension 明确登记依赖的真实 dev 子进程。 */ + const running = startCli(['dev'], root); + + await waitForOutput(running, stdout => stdout.includes('dev: success'), 'initial build graph build'); + await fs.writeFile(helper, 'export const value = "second";\n'); + await waitForOutput( + running, + (stdout, stderr) => stdout.match(/dev: success/g)?.length === 2 && stderr.includes('fixture: build graph second'), + 'registered build graph dependency rebuild', + ); + + running.child.kill('SIGINT'); + expect((await waitForExit(running)).code).toBe(130); + }, 20_000); + + it('rebuilds a local MCP bundle when its resolved package dependency changes', async () => { + /** 官方 MCP Rolldown 模块图监听测试使用的规范工程根。 */ + const root = await temporaryProject(); + await writeValidProject(root); + /** 临时工程按公开包名加载的 MCP Extension 代理目录。 */ + /** 真实 MCP Extension 构建产物入口。 */ + const extensionEntry = path.resolve(import.meta.dirname, '../../../extensions/mcp/dist/index.mjs'); + await writePackageProxy(root, '@tokenroll/acplugin-extension-mcp', extensionEntry); + /** 只通过 Server import graph 可达、且位于默认忽略目录的测试依赖。 */ + const helperPackage = path.join(root, 'node_modules/mcp-watch-helper'); + await fs.mkdir(helperPackage, { recursive: true }); + await fs.writeFile(path.join(helperPackage, 'package.json'), JSON.stringify({ + name: 'mcp-watch-helper', version: '1.0.0', type: 'module', exports: './index.js', license: 'MIT', + })); + await fs.writeFile(path.join(helperPackage, 'LICENSE'), 'MCP watch fixture license.\n'); + await fs.writeFile(path.join(helperPackage, 'index.js'), 'export const serverName = "first-server";\n'); + await fs.mkdir(path.join(root, 'src/mcp/local-tools'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/mcp/local-tools/mcp.ts'), ` +import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; +export default { transport: 'stdio' } satisfies McpServer; +`); + await fs.writeFile(path.join(root, 'src/mcp/local-tools/server.ts'), ` +import { serverName } from 'mcp-watch-helper'; +let buffer = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + buffer += chunk; + const lines = buffer.split('\\n'); + buffer = lines.pop() ?? ''; + for (const line of lines.filter(Boolean)) { + const message = JSON.parse(line); + if (message.method === 'initialize') { + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, result: { + protocolVersion: message.params.protocolVersion, + capabilities: { tools: {} }, + serverInfo: { name: serverName, version: '1.0.0' }, + } }) + '\\n'); + } else if (message.method === 'tools/list') { + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, result: { tools: [] } }) + '\\n'); + } + } +}); +`); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), `${platformImports} +import mcp from '@tokenroll/acplugin-extension-mcp'; +export default { + ${platformField} + name: 'mcp-watch-plugin', + version: '1.0.0', + description: 'MCP bundle watch fixture.', + extensions: [mcp()], + build: { strict: false }, +}; +`); + /** 持续监听官方 MCP Bundle 模块图的真实 dev 子进程。 */ + const running = startCli(['dev'], root); + + await waitForOutput(running, stdout => stdout.includes('dev: success'), 'initial MCP graph build'); + /** 首次生成的 MCP Server 应内联依赖原始值。 */ + const generated = path.join(root, 'dist/codex/plugin/mcp/local-tools/server.mjs'); + expect(await fs.readFile(generated, 'utf8')).toContain('first-server'); + await fs.writeFile(path.join(helperPackage, 'index.js'), 'export const serverName = "second-server";\n'); + await waitForOutput(running, stdout => stdout.match(/dev: success/g)?.length === 2, 'MCP package dependency rebuild'); + expect(await fs.readFile(generated, 'utf8')).toContain('second-server'); + + running.child.kill('SIGINT'); + expect((await waitForExit(running)).code).toBe(130); + }, 20_000); + + // 目录名 dist 不是固定输出语义;自定义 outDir 后它可以合法承载规范源码或 Public。 + it('watches a custom srcDir named dist while excluding only the resolved outDir', async () => { + /** 自定义源码与输出目录测试使用的工程根。 */ + const root = await temporaryProject(); + await fs.writeFile(path.join(root, 'package.json'), '{"name":"custom-source-plugin","type":"module"}\n'); + /** 名为 dist 的合法源码目录及其最小 Skill。 */ + const skill = path.join(root, 'dist/skills/hello/SKILL.md'); + await fs.mkdir(path.dirname(skill), { recursive: true }); + await fs.writeFile(skill, `--- +description: Say hello from a custom source directory. +--- +First custom source build. +`); + /** 位于工程包内、最近 package root 等于 projectRoot 的本地 descriptor。 */ + const descriptor = path.join(root, 'dist/local-extension/descriptor.ts'); + await fs.mkdir(path.dirname(descriptor), { recursive: true }); + await fs.writeFile(descriptor, `export default 'local';\n`); + /** 构造本地 Extension 时与 CLI Bundle 共享品牌 Symbol 的已构建 Facade。 */ + const facade = '@tokenroll/acplugin/sdk'; + await fs.writeFile(path.join(root, 'acplugin.config.ts'), `${platformImports} +import { defineExtension } from '${facade}'; +const extension = defineExtension({ + id: 'local-extension', + apiVersion: '1', + resourceRoots: ['local-extension'], + createSession: () => ({ + async discover(context) { + const root = await context.roots['local-extension']; + await context.modules.loadDefault({ id: 'local-extension', entry: await context.sources.file(root, 'descriptor.ts') }); + return undefined; + }, + validate: () => ({ state: {}, subjects: [] }), + build: () => ({ state: {} }), + contributors: [], + }), +}); +export default { + ${platformField} + name: 'custom-source-plugin', + version: '1.0.0', + description: 'Custom source fixture.', + srcDir: 'dist', + build: { outDir: 'output' }, + extensions: [extension], +}; +`); + /** 只排除解析后 output 的真实 dev 子进程。 */ + const running = startCli(['dev'], root); + + await waitForOutput(running, stdout => stdout.includes('dev: success'), 'custom source dev build'); + /** 把一次编辑拆成跨越基础防抖窗口的两段写入,模拟 macOS FSEvents 的延迟 change。 */ + const skillHandle = await fs.open(skill, 'w'); + try { + await skillHandle.writeFile(`--- +description: Say hello from a custom source directory. +--- +Second custom source build. +`); + await new Promise(resolve => setTimeout(resolve, 70)); + await skillHandle.writeFile('Additional content from the same editor save.\n'); + } finally { + await skillHandle.close(); + } + await waitForOutput(running, stdout => stdout.match(/dev: success/g)?.length === 2, 'custom source rebuild'); + /** 第二次重建写入自定义 outDir 的最终 Codex Skill。 */ + const generated = path.join(root, 'output/codex/plugin/skills/hello/SKILL.md'); + expect(await fs.readFile(generated, 'utf8')).toContain('Second custom source build.'); + // 等待可能由 outDir 交换错误触发的额外事件,确认监听不会形成自激重建循环。 + await new Promise(resolve => setTimeout(resolve, 250)); + expect(running.stdout().match(/dev: success/g)).toHaveLength(2); + + running.child.kill('SIGINT'); + /** 自定义目录重建完成后的信号退出状态。 */ + const stopped = await waitForExit(running); + expect(stopped.code).toBe(130); + }, 20_000); + + // JSON dev 需要跨多次重建保持 stdout 为空,直到退出时才能形成一个完整文档。 + it('emits exactly one final JSON document after multiple dev rebuilds', async () => { + /** JSON dev 流测试使用的规范工程根。 */ + const root = await temporaryProject(); + await writeValidProject(root); + /** 触发第二次成功重建的 Skill 源文件。 */ + const skill = path.join(root, 'src/skills/hello/SKILL.md'); + /** JSON 模式持续运行的真实 dev 子进程。 */ + const running = startCli(['dev', '--json'], root); + + await waitForOutput(running, (_stdout, stderr) => stderr.includes('dev: success'), 'initial JSON dev build'); + expect(running.stdout()).toBe(''); + await fs.writeFile(skill, `--- +description: Say hello in JSON mode. +--- +Say hello after a JSON rebuild. +`); + await waitForOutput(running, (_stdout, stderr) => stderr.match(/dev: success/g)?.length === 2, 'second JSON dev build'); + expect(running.stdout()).toBe(''); + + running.child.kill('SIGINT'); + /** SIGINT 后只包含最终 BuildReport 的进程输出。 */ + const stopped = await waitForExit(running); + expect(stopped.code).toBe(130); + expect(JSON.parse(stopped.stdout)).toMatchObject({ command: 'dev', success: true, committed: true }); + }, 20_000); +}); diff --git a/packages/test/test/cli/init.test.ts b/packages/test/test/cli/init.test.ts new file mode 100644 index 0000000..6355bcc --- /dev/null +++ b/packages/test/test/cli/init.test.ts @@ -0,0 +1,109 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import * as acplugin from '@tokenroll/acplugin'; +import { initializeProject } from '@tokenroll/acplugin'; + +/** 当前测试创建并在 afterEach 中统一删除的临时目录。 */ +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('init', () => { + it('creates the minimal strict dual-Platform project without fake Extension source', async () => { + /** 最小工程脚手架测试使用的父目录。 */ + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-init-test-')); + roots.push(cwd); + /** 非交互初始化返回的脚手架摘要。 */ + const result = await initializeProject({ cwd, directory: 'demo-plugin', yes: true }); + /** 由公开 package manifests 生成、并由脚手架消费的当前生态版本快照。 */ + const versions = JSON.parse(await fs.readFile( + new URL('../../../acplugin/src/ecosystem/versions.json', import.meta.url), + 'utf8', + )) as Record; + + expect(result.directory).toBe('demo-plugin'); + expect(result.platforms).toEqual(['claude-code', 'codex']); + expect(result.extensions).toEqual([]); + /** 默认配置通过两个独立 Platform package 的显式默认导入构建。 */ + const config = await fs.readFile(path.join(cwd, 'demo-plugin/acplugin.config.ts'), 'utf8'); + expect(config).toContain(`import claudeCode from '@tokenroll/acplugin-platform-claude-code';`); + expect(config).toContain(`import codex from '@tokenroll/acplugin-platform-codex';`); + expect(config).toContain('platforms: [claudeCode(), codex()]'); + expect(await fs.readFile(path.join(cwd, 'demo-plugin/src/skills/demo-plugin/SKILL.md'), 'utf8')).toContain('description:'); + expect(JSON.parse(await fs.readFile(path.join(cwd, 'demo-plugin/package.json'), 'utf8'))).toMatchObject({ + engines: { node: '^20.19.0 || ^22.13.0 || >=23.5.0' }, + devDependencies: { + '@tokenroll/acplugin-platform-claude-code': `^${versions['@tokenroll/acplugin-platform-claude-code']}`, + '@tokenroll/acplugin-platform-codex': `^${versions['@tokenroll/acplugin-platform-codex']}`, + 'typescript': '^7.0.2', + }, + }); + }); + + it('adds selected Extensions and a built-in Runtime entry without fake handlers or servers', async () => { + /** 可选 Extension 脚手架测试使用的父目录。 */ + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-init-test-')); + roots.push(cwd); + /** 同时启用两个官方 Extension 和 Core Runtime 模板的初始化结果。 */ + const result = await initializeProject({ + cwd, + directory: 'extension-plugin', + yes: true, + hooks: true, + mcp: true, + nodeRuntime: true, + }); + /** 已生成工程的绝对路径。 */ + const project = path.join(cwd, 'extension-plugin'); + + expect(await fs.readFile(path.join(project, 'acplugin.config.ts'), 'utf8')).toContain('extensions: [hooks(), mcp()]'); + expect(await fs.readdir(path.join(project, 'src/hooks'))).toEqual([]); + expect(await fs.readdir(path.join(project, 'src/mcp'))).toEqual([]); + await expect(fs.access(path.join(project, 'src/runtime/runtime.ts'))).rejects.toThrow(); + expect(await fs.readFile(path.join(project, 'src/runtime/main.ts'), 'utf8')).toContain('ACPlugin Node runtime is ready.'); + expect(result.extensions).toEqual([ + '@tokenroll/acplugin-extension-hooks', + '@tokenroll/acplugin-extension-mcp', + ]); + expect(JSON.parse(await fs.readFile(path.join(project, 'package.json'), 'utf8')).devDependencies) + .not.toHaveProperty('@tokenroll/acplugin-extension-node-runtime'); + }); + + it('writes any explicit subset of the six official Platform factories', async () => { + /** 六 Platform 脚手架测试使用的父目录。 */ + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-init-test-')); + roots.push(cwd); + /** 显式选择所有内置 Platform 的初始化结果。 */ + const result = await initializeProject({ + cwd, + directory: 'all-platforms', + yes: true, + platforms: ['claude-code', 'codex', 'cursor', 'antigravity', 'opencode', 'pi'], + }); + /** 需要能被 TypeScript 配置加载器执行的配置源码。 */ + const config = await fs.readFile(path.join(cwd, 'all-platforms/acplugin.config.ts'), 'utf8'); + + expect(result.platforms).toEqual(['claude-code', 'codex', 'cursor', 'antigravity', 'opencode', 'pi']); + expect(config).toContain('claudeCode(), codex(), cursor(), antigravity(), openCode(), pi()'); + expect(config).toContain(`import pi from '@tokenroll/acplugin-platform-pi';`); + }); + + it('refuses a non-empty destination', async () => { + /** 非空目标拒绝测试使用的父目录。 */ + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-init-test-')); + roots.push(cwd); + await fs.mkdir(path.join(cwd, 'existing')); + await fs.writeFile(path.join(cwd, 'existing/user.txt'), 'keep'); + + await expect(initializeProject({ cwd, directory: 'existing', yes: true })).rejects.toThrow('not empty'); + expect(await fs.readFile(path.join(cwd, 'existing/user.txt'), 'utf8')).toBe('keep'); + }); + + it('keeps the specialized init error outside the public facade', () => { + expect('InitError' in acplugin).toBe(false); + }); +}); diff --git a/packages/test/test/extensions/hooks.test.ts b/packages/test/test/extensions/hooks.test.ts new file mode 100644 index 0000000..133e776 --- /dev/null +++ b/packages/test/test/extensions/hooks.test.ts @@ -0,0 +1,83 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import hooks, { + CLAUDE_CODE_PLATFORM_EVENTS, + EXTENSION_NAME, + HOOK_EVENTS, +} from '@tokenroll/acplugin-extension-hooks'; + +/** 跨包 Hooks 契约测试使用的仓库根目录。 */ +const repositoryRoot = fileURLToPath(new URL('../../../..', import.meta.url)); + +/** + * 递归读取一个源码目录中的全部 TypeScript 文件。 + * + * @param directory 当前需要遍历的绝对目录。 + * @returns 按路径稳定拼接的源码文本。 + */ +async function sourceTree(directory: string): Promise { + /** 当前目录按名称稳定排序的文件系统项。 */ + const entries = (await fs.readdir(directory, { withFileTypes: true })) + .sort((left, right) => left.name.localeCompare(right.name, 'en')); + /** 当前目录和子目录累计的 TypeScript 源码。 */ + const sources: string[] = []; + for (const entry of entries) { + /** 当前目录项的绝对路径。 */ + const target = path.join(directory, entry.name); + if (entry.isDirectory()) + sources.push(await sourceTree(target)); + else if (entry.isFile() && entry.name.endsWith('.ts')) + sources.push(await fs.readFile(target, 'utf8')); + } + return sources.join('\n'); +} + +describe('official Hooks Extension ecosystem contract', () => { + it('exposes the canonical author API and immutable Extension definition', () => { + /** 从正式公开包创建的 Hooks Extension。 */ + const extension = hooks(); + expect(EXTENSION_NAME).toBe('@tokenroll/acplugin-extension-hooks'); + expect(Object.isFrozen(extension)).toBe(true); + expect(extension.id).toBe('hooks'); + expect(extension.apiVersion).toBe('1'); + expect(extension.resourceRoots).toEqual(['hooks']); + expect(extension.options).toEqual({}); + expect(Object.isFrozen(extension.resourceRoots)).toBe(true); + expect(Object.isFrozen(extension.options)).toBe(true); + expect(HOOK_EVENTS).toEqual([ + 'SessionStart', 'SessionEnd', 'UserPromptSubmit', 'PreToolUse', + 'PermissionRequest', 'PostToolUse', 'PreCompact', 'PostCompact', + 'SubagentStart', 'SubagentStop', 'Stop', + ]); + expect(CLAUDE_CODE_PLATFORM_EVENTS).toContain('Setup'); + expect(CLAUDE_CODE_PLATFORM_EVENTS).toContain('ElicitationResult'); + }); + + it('keeps Platform packages independent and removes the retired Hooks Module implementation', async () => { + /** Hooks Extension 发布包的 workspace manifest。 */ + const manifest = JSON.parse(await fs.readFile( + path.join(repositoryRoot, 'packages/extensions/hooks/package.json'), + 'utf8', + )) as { + readonly name: string; + readonly peerDependencies?: Record; + readonly dependencies?: Record; + }; + /** Claude Code 与 Codex Platform 的完整生产源码。 */ + const platforms = await Promise.all([ + sourceTree(path.join(repositoryRoot, 'packages/platforms/claude-code/src')), + sourceTree(path.join(repositoryRoot, 'packages/platforms/codex/src')), + ]); + /** Hooks Extension 自身的完整生产源码。 */ + const extensionSource = await sourceTree(path.join(repositoryRoot, 'packages/extensions/hooks/src')); + + expect(manifest.name).toBe('@tokenroll/acplugin-extension-hooks'); + expect(manifest.peerDependencies).toEqual({ '@tokenroll/acplugin': 'workspace:^' }); + expect(manifest.dependencies).toBeUndefined(); + expect(platforms.join('\n')).not.toContain('@tokenroll/acplugin-extension-hooks'); + expect(extensionSource).not.toMatch(/\b(?:AcpluginModule|ModuleGenerateContext|TargetContribution|TargetId)\b/); + await expect(fs.access(path.join(repositoryRoot, 'packages/module-hooks'))).rejects.toThrow(); + }); +}); diff --git a/packages/test/test/migration.test.ts b/packages/test/test/migration.test.ts new file mode 100644 index 0000000..aafad5c --- /dev/null +++ b/packages/test/test/migration.test.ts @@ -0,0 +1,669 @@ +import { createHash } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { migrate } from '../../acplugin/src/migration/index.js'; +import { parseGitHubSource } from '../../acplugin/src/migration/legacy/github.js'; + +/** 当前测试创建并在 afterEach 中统一删除的临时目录。 */ +const roots: string[] = []; +/** 仓库内用于验证旧 Claude 工程迁移的固定 Fixture。 */ +const legacyProjectFixture = path.resolve(import.meta.dirname, '../fixtures/migration/claude-project'); + +/** + * 创建四种资源都包含规范化 ID 冲突的旧 Claude 工程。 + * + * @param root 当前测试的临时工作目录。 + * @param directory 来源工程目录名。 + * @param reversed 是否反转文件创建和 MCP 对象插入顺序。 + * @returns 已写入完整碰撞矩阵的旧工程路径。 + */ +async function collisionProject(root: string, directory: string, reversed: boolean): Promise { + /** 当前碰撞矩阵使用的旧工程根。 */ + const source = path.join(root, directory); + /** `foo!` 与 `foo` 归一为同一 base,显式 `foo-2` 必须优先保留。 */ + const canonicalOrder = ['foo!', 'foo', 'foo-2']; + /** 文件创建与 MCP JSON 插入使用的当前顺序。 */ + const names = reversed ? [...canonicalOrder].reverse() : canonicalOrder; + await fs.mkdir(path.join(source, '.claude/commands'), { recursive: true }); + await fs.mkdir(path.join(source, '.claude/agents'), { recursive: true }); + await fs.mkdir(path.join(source, '.claude/skills'), { recursive: true }); + for (const name of names) { + await fs.writeFile(path.join(source, '.claude/commands', `${name}.md`), `---\ndescription: Command ${name}.\n---\nCommand body ${name}.\n`); + await fs.writeFile(path.join(source, '.claude/agents', `${name}.md`), `---\ndescription: Agent ${name}.\n---\nAgent body ${name}.\n`); + await fs.mkdir(path.join(source, '.claude/skills', name), { recursive: true }); + await fs.writeFile(path.join(source, '.claude/skills', name, 'SKILL.md'), `---\ndescription: Skill ${name}.\n---\nSkill body ${name}.\n`); + } + /** MCP 对象额外加入大小写冲突,不受宿主文件系统大小写能力限制。 */ + const mcpNames = reversed ? ['foo-2', 'foo', 'foo!', 'Foo'] : ['Foo', 'foo!', 'foo', 'foo-2']; + /** 每个旧 MCP 名称对应的可区分安全远程声明。 */ + const mcpServers = Object.fromEntries(mcpNames.map(name => [name, { + type: 'http', + url: `https://mcp.example.com/${name === 'foo!' ? 'bang' : name}`, + }])); + await fs.writeFile(path.join(source, '.mcp.json'), JSON.stringify({ mcpServers })); + return source; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('legacy Migration', () => { + it('rejects GitHub command/path injection before download', () => { + expect(() => parseGitHubSource('github:owner/repo#main\ntouch injected')).toThrow('branch is invalid'); + expect(() => parseGitHubSource('github:owner/repo#../../../../user')).toThrow('branch is invalid'); + expect(() => parseGitHubSource('https://github.com/owner/repo/tree/main/../../outside')).toThrow('must stay inside'); + }); + + it('allocates collision-safe deterministic IDs per resource namespace without stealing explicit suffixes', async () => { + /** 两种发现/对象顺序共享的临时工作目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-migration-collision-test-')); + roots.push(root); + /** 正向创建和 MCP key 顺序的旧工程。 */ + await collisionProject(root, 'source-forward', false); + /** 反向创建和 MCP key 顺序的语义相同旧工程。 */ + await collisionProject(root, 'source-reverse', true); + /** 第一份完整碰撞矩阵迁移报告。 */ + const first = await migrate({ + cwd: root, + source: 'source-forward', + destination: 'output-forward', + name: 'collision-fixture', + description: 'Collision fixture.', + }); + /** 第二份只改变发现/对象顺序的迁移报告。 */ + const second = await migrate({ + cwd: root, + source: 'source-reverse', + destination: 'output-reverse', + name: 'collision-fixture', + description: 'Collision fixture.', + }); + + expect(first.success, JSON.stringify(first.diagnostics)).toBe(true); + expect(second.success, JSON.stringify(second.diagnostics)).toBe(true); + /** kind 表示当前必须拥有独立 namespace 的规范资源类别。 */ + for (const kind of ['command', 'skill', 'agent']) { + /** 当前类别最终分配且按报告顺序出现的 ID。 */ + const ids = first.items.filter(item => item.kind === kind).map(item => item.id); + expect(ids).toEqual(['foo', 'foo-2', 'foo-3']); + expect(new Set(first.items.filter(item => item.kind === kind).map(item => item.destination)).size).toBe(3); + } + expect(first.items.filter(item => item.kind === 'mcp').map(item => item.id)).toEqual(['foo', 'foo-2', 'foo-3', 'foo-4']); + expect(new Set(first.items.filter(item => item.kind === 'mcp').map(item => item.destination)).size).toBe(4); + expect(first.items.find(item => item.kind === 'command' && item.id === 'foo')).toMatchObject({ + source: '.claude/commands/foo!.md', destination: 'src/commands/foo.md', outcome: 'degraded', + }); + expect(first.items.find(item => item.kind === 'command' && item.id === 'foo-2')).toMatchObject({ + source: '.claude/commands/foo-2.md', destination: 'src/commands/foo-2.md', + }); + expect(first.items.find(item => item.kind === 'command' && item.id === 'foo-3')).toMatchObject({ + source: '.claude/commands/foo.md', destination: 'src/commands/foo-3.md', outcome: 'degraded', + }); + expect(await fs.readFile(path.join(root, 'output-forward/src/commands/foo.md'), 'utf8')).toContain('Command body foo!.'); + expect(await fs.readFile(path.join(root, 'output-forward/src/commands/foo-2.md'), 'utf8')).toContain('Command body foo-2.'); + expect(await fs.readFile(path.join(root, 'output-forward/src/commands/foo-3.md'), 'utf8')).toContain('Command body foo.'); + /** 两次提交后持久化的稳定报告字节。 */ + const firstReport = await fs.readFile(path.join(root, 'output-forward/.acplugin-migration/report.json'), 'utf8'); + /** 反向输入产生的稳定报告字节。 */ + const secondReport = await fs.readFile(path.join(root, 'output-reverse/.acplugin-migration/report.json'), 'utf8'); + expect(secondReport).toBe(firstReport); + }); + + it('allocates unique deterministic workspace directories for --all Marketplace migration', async () => { + /** workspace 目录冲突测试使用的临时工作目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-marketplace-collision-test-')); + roots.push(root); + /** 包含三个规范化后冲突名称的旧 Marketplace。 */ + const marketplace = path.join(root, 'marketplace'); + await fs.mkdir(path.join(marketplace, '.claude-plugin'), { recursive: true }); + /** Marketplace 条目及稳定来源目录;显式 foo-2 必须保留自己的目录。 */ + const plugins = [ + { name: 'foo!', source: './plugins/a', description: 'Foo bang.' }, + { name: 'foo', source: './plugins/b', description: 'Foo plain.' }, + { name: 'foo-2', source: './plugins/c', description: 'Foo explicit.' }, + ]; + await fs.writeFile(path.join(marketplace, '.claude-plugin/marketplace.json'), JSON.stringify({ + name: 'collision-marketplace', + plugins, + })); + /** plugin 表示当前需要具备至少一个真实资源的 Marketplace 成员。 */ + for (const plugin of plugins) { + /** 当前 Marketplace 成员的最小旧 Skill 目录。 */ + const pluginRoot = path.join(marketplace, plugin.source, 'skills/hello'); + await fs.mkdir(pluginRoot, { recursive: true }); + await fs.writeFile(path.join(pluginRoot, 'SKILL.md'), `---\ndescription: ${plugin.name}.\n---\n${plugin.name}.\n`); + } + + /** 批量迁移产生的 workspace 报告。 */ + const report = await migrate({ cwd: root, source: 'marketplace', destination: 'workspace', all: true }); + + expect(report.success, JSON.stringify(report.diagnostics)).toBe(true); + expect(report.projects).toEqual(['foo', 'foo-2', 'foo-3']); + expect(await fs.readFile(path.join(root, 'workspace/pnpm-workspace.yaml'), 'utf8')).toBe('packages:\n - foo\n - foo-2\n - foo-3\n'); + await fs.access(path.join(root, 'workspace/foo/src/skills/hello/SKILL.md')); + await fs.access(path.join(root, 'workspace/foo-2/src/skills/hello/SKILL.md')); + await fs.access(path.join(root, 'workspace/foo-3/src/skills/hello/SKILL.md')); + expect(await fs.readFile(path.join(root, 'workspace/foo/acplugin.config.ts'), 'utf8')).toContain('name: "foo"'); + expect(await fs.readFile(path.join(root, 'workspace/foo-2/acplugin.config.ts'), 'utf8')).toContain('name: "foo-2"'); + expect(await fs.readFile(path.join(root, 'workspace/foo-3/acplugin.config.ts'), 'utf8')).toContain('name: "foo-3"'); + }); + + it('creates a canonical project and preserves unmapped resources in a sidecar', async () => { + /** 正常迁移测试使用的临时工作目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-migration-test-')); + roots.push(root); + /** 固定旧 Claude 工程 Fixture 的迁移来源。 */ + const source = legacyProjectFixture; + /** 规范工程和未映射 Sidecar 的迁移报告。 */ + const report = await migrate({ + cwd: root, + source, + destination: 'migrated', + name: 'migrated-plugin', + description: 'Migrated fixture.', + }); + + expect(report.success).toBe(true); + expect(report.sourceType).toBe('project'); + expect(report.items).toContainEqual(expect.objectContaining({ + kind: 'instruction', + outcome: 'unmapped', + fields: [expect.objectContaining({ field: 'content', outcome: 'unmapped' })], + })); + expect(await fs.readFile(path.join(root, 'migrated/src/skills/my-skill/SKILL.md'), 'utf8')).toContain('description:'); + expect(JSON.parse(await fs.readFile(path.join(root, 'migrated/package.json'), 'utf8'))).toMatchObject({ + devDependencies: { typescript: '^7.0.2' }, + }); + expect(JSON.parse(await fs.readFile(path.join(root, 'migrated/.acplugin-migration/report.json'), 'utf8'))).toMatchObject({ schemaVersion: '1' }); + }); + + it('strict dry-run fails on preserved unmapped resources and writes no destination', async () => { + /** 严格 dry-run 使用的临时工作目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-migration-test-')); + roots.push(root); + /** 包含越界 Instructions 的固定旧工程来源。 */ + const source = legacyProjectFixture; + /** 严格模式下因 unmapped 资源失败的 dry-run 报告。 */ + const report = await migrate({ + cwd: root, + source, + destination: 'migrated', + name: 'migrated-plugin', + description: 'Migrated fixture.', + strict: true, + dryRun: true, + }); + + expect(report.success).toBe(false); + await expect(fs.access(path.join(root, 'migrated'))).rejects.toThrow(); + }); + + it('migrates all marketplace plugins into a pnpm workspace at a nested destination', async () => { + /** Marketplace 多工程迁移测试使用的工作目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-migration-test-')); + roots.push(root); + /** 动态创建的旧 Claude Marketplace 根目录。 */ + const marketplace = path.join(root, 'marketplace'); + await fs.mkdir(path.join(marketplace, '.claude-plugin'), { recursive: true }); + await fs.writeFile(path.join(marketplace, '.claude-plugin/marketplace.json'), JSON.stringify({ + name: 'fixture-marketplace', + plugins: [ + { name: 'first-plugin', description: 'First plugin.', version: '1.0.0', source: './plugins/first' }, + { name: 'second-plugin', description: 'Second plugin.', version: '1.0.0', source: './plugins/second' }, + ], + })); + for (const plugin of ['first', 'second']) { + await fs.mkdir(path.join(marketplace, 'plugins', plugin, 'skills', 'hello'), { recursive: true }); + await fs.writeFile(path.join(marketplace, 'plugins', plugin, 'skills/hello/SKILL.md'), `--- +description: Hello from ${plugin}. +--- +Run the ${plugin} workflow. +`); + } + + /** 全量迁移两个 Plugin 后的 Workspace 报告。 */ + const report = await migrate({ + cwd: root, + source: 'marketplace', + destination: 'nested/migrated', + all: true, + }); + + expect(report).toMatchObject({ success: true, sourceType: 'marketplace', projects: ['first-plugin', 'second-plugin'] }); + expect(report.items).toContainEqual(expect.objectContaining({ + kind: 'marketplace', + id: 'fixture-marketplace', + outcome: 'unmapped', + fields: expect.arrayContaining([ + expect.objectContaining({ field: 'name', source: '.claude-plugin/marketplace.json', outcome: 'unmapped' }), + expect.objectContaining({ field: 'plugin-order', destination: '.acplugin-migration/report.json', outcome: 'unmapped' }), + ]), + })); + expect(await fs.readFile(path.join(root, 'nested/migrated/pnpm-workspace.yaml'), 'utf8')).toContain('first-plugin'); + await fs.access(path.join(root, 'nested/migrated/second-plugin/src/skills/hello/SKILL.md')); + }); + + it('writes a selected marketplace plugin directly as one canonical project', async () => { + /** 单 Plugin Marketplace 迁移测试使用的工作目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-migration-test-')); + roots.push(root); + /** 仅包含一个可选择条目的旧 Marketplace。 */ + const marketplace = path.join(root, 'marketplace'); + await fs.mkdir(path.join(marketplace, '.claude-plugin'), { recursive: true }); + await fs.mkdir(path.join(marketplace, 'plugins/selected/skills/hello'), { recursive: true }); + await fs.writeFile(path.join(marketplace, '.claude-plugin/marketplace.json'), JSON.stringify({ + name: 'single-fixture', + plugins: [{ name: 'selected-plugin', description: 'Selected plugin.', source: './plugins/selected' }], + })); + await fs.writeFile(path.join(marketplace, 'plugins/selected/skills/hello/SKILL.md'), '---\ndescription: Hello.\n---\nHello.\n'); + + /** --plugin 输出根本身就是可安装和构建的规范工程。 */ + const report = await migrate({ + cwd: root, + source: 'marketplace', + destination: 'selected-output', + plugin: 'selected-plugin', + }); + + expect(report).toMatchObject({ success: true, projects: ['.'] }); + await fs.access(path.join(root, 'selected-output/acplugin.config.ts')); + await fs.access(path.join(root, 'selected-output/src/skills/hello/SKILL.md')); + await expect(fs.access(path.join(root, 'selected-output/pnpm-workspace.yaml'))).rejects.toThrow(); + await expect(fs.access(path.join(root, 'selected-output/selected-plugin'))).rejects.toThrow(); + }); + + it('retains and validates a Marketplace plugin whose only resource is remote MCP', async () => { + /** MCP-only Marketplace 回归测试使用的临时工作目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-migration-test-')); + roots.push(root); + /** 只包含一个远程 MCP Plugin 的旧 Marketplace。 */ + const marketplace = path.join(root, 'marketplace'); + await fs.mkdir(path.join(marketplace, '.claude-plugin'), { recursive: true }); + await fs.mkdir(path.join(marketplace, 'plugins/remote'), { recursive: true }); + await fs.writeFile(path.join(marketplace, '.claude-plugin/marketplace.json'), JSON.stringify({ + name: 'mcp-marketplace', + plugins: [{ name: 'remote-tools', description: 'Remote tools.', source: './plugins/remote' }], + })); + await fs.writeFile(path.join(marketplace, 'plugins/remote/.mcp.json'), JSON.stringify({ + mcpServers: { docs: { type: 'http', url: 'https://mcp.example.com/mcp' } }, + })); + + /** --plugin 不能因缺少 Core Component 丢弃 MCP-only 清单条目。 */ + const report = await migrate({ + cwd: root, + source: 'marketplace', + destination: 'remote-output', + plugin: 'remote-tools', + }); + + expect(report).toMatchObject({ success: true, projects: ['.'] }); + expect(report.items).toContainEqual(expect.objectContaining({ + kind: 'metadata', + id: 'remote-tools', + fields: expect.arrayContaining([ + expect.objectContaining({ field: 'name', source: '.claude-plugin/marketplace.json', outcome: 'mapped' }), + ]), + })); + expect(report.items).toContainEqual(expect.objectContaining({ kind: 'mcp', id: 'docs', outcome: 'migrated' })); + await fs.access(path.join(root, 'remote-output/src/mcp/docs/mcp.ts')); + }); + + it('rejects marketplace sources that resolve outside the source tree', async () => { + /** Marketplace 路径逃逸测试使用的工作目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-migration-test-')); + roots.push(root); + /** 声明越界 Plugin source 的 Marketplace 根。 */ + const marketplace = path.join(root, 'marketplace'); + await fs.mkdir(path.join(marketplace, '.claude-plugin'), { recursive: true }); + await fs.mkdir(path.join(root, 'outside', 'skills', 'escape'), { recursive: true }); + await fs.writeFile(path.join(root, 'outside/skills/escape/SKILL.md'), '---\ndescription: Escape.\n---\nEscape.\n'); + await fs.writeFile(path.join(marketplace, '.claude-plugin/marketplace.json'), JSON.stringify({ + name: 'unsafe-marketplace', + plugins: [{ name: 'escape', description: 'Escape.', source: '../outside' }], + })); + + await expect(migrate({ cwd: root, source: 'marketplace', destination: 'migrated', all: true })).rejects.toThrow('must stay inside'); + await expect(fs.access(path.join(root, 'migrated'))).rejects.toThrow(); + }); + + it('never copies literal MCP credentials into canonical or unmapped output', async () => { + /** MCP 凭据脱敏测试使用的工作目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-migration-test-')); + roots.push(root); + /** 动态创建且含明文凭据的旧 Claude 工程。 */ + const source = path.join(root, 'legacy-project'); + await fs.mkdir(path.join(source, '.claude'), { recursive: true }); + await fs.writeFile(path.join(source, '.mcp.json'), JSON.stringify({ + mcpServers: { + secret: { + type: 'http', + url: 'https://user:password@example.com/mcp?token=top-secret', + headers: { Authorization: 'Bearer top-secret' }, + }, + }, + })); + + /** 无法安全自动迁移 MCP 后的报告。 */ + const report = await migrate({ + cwd: root, source: 'legacy-project', destination: 'migrated', + name: 'safe-plugin', description: 'Safe migration.', + }); + expect(report.success, JSON.stringify(report.diagnostics)).toBe(true); + /** 未映射 MCP Sidecar 中应完成脱敏的文本。 */ + const output = await fs.readFile(path.join(root, 'migrated/.acplugin-migration/unmapped/mcp/secret.json'), 'utf8'); + + expect(report.items).toContainEqual(expect.objectContaining({ kind: 'mcp', id: 'secret', outcome: 'unmapped' })); + expect(output).not.toContain('top-secret'); + expect(output).not.toContain('password'); + expect(output).toContain(''); + }); + + it('preserves Hook implementation files without treating them as trusted canonical handlers', async () => { + /** Hook 引用文件保留测试使用的工作目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-migration-test-')); + roots.push(root); + /** 动态创建且引用本地脚本的旧 Claude 工程。 */ + const source = path.join(root, 'legacy-project'); + await fs.mkdir(path.join(source, '.claude'), { recursive: true }); + await fs.mkdir(path.join(source, 'scripts'), { recursive: true }); + await fs.writeFile(path.join(source, '.claude/settings.json'), JSON.stringify({ + hooks: { + PreToolUse: [{ hooks: [{ type: 'command', command: 'bash "${CLAUDE_PROJECT_DIR}/scripts/check.sh"' }] }], + }, + })); + await fs.writeFile(path.join(source, 'scripts/check.sh'), '#!/bin/sh\nexit 0\n'); + + /** Hook 配置与实现均作为未映射内容保留的报告。 */ + const report = await migrate({ + cwd: root, + source: 'legacy-project', + destination: 'migrated', + name: 'hook-project', + description: 'Hook migration fixture.', + }); + expect(report.success, JSON.stringify(report.diagnostics)).toBe(true); + + expect(report.items).toContainEqual(expect.objectContaining({ + kind: 'hook-file', + outcome: 'unmapped', + destination: '.acplugin-migration/unmapped/hook-files/scripts/check.sh', + })); + expect(await fs.readFile(path.join(root, 'migrated/.acplugin-migration/unmapped/hook-files/scripts/check.sh'), 'utf8')).toContain('exit 0'); + await expect(fs.access(path.join(root, 'migrated/src/hooks'))).rejects.toThrow(); + }); + + it('does not create nested destination parents during dry-run', async () => { + /** 嵌套 dry-run 目标测试使用的工作目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-migration-test-')); + roots.push(root); + /** 固定旧工程 Fixture 的来源路径。 */ + const source = legacyProjectFixture; + await migrate({ + cwd: root, + source, + destination: 'not-created/nested/migrated', + name: 'migrated-plugin', + description: 'Dry migration fixture.', + dryRun: true, + }); + + await expect(fs.access(path.join(root, 'not-created'))).rejects.toThrow(); + }); + + it('preserves Command, Skill, Agent, metadata, and binary fields without overstating lossy resources', async () => { + /** 字段级保真测试使用的临时工作目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-migration-test-')); + roots.push(root); + /** 动态创建的完整旧 Claude Code Plugin。 */ + const source = path.join(root, 'legacy-plugin'); + await fs.mkdir(path.join(source, '.claude-plugin'), { recursive: true }); + await fs.mkdir(path.join(source, 'commands'), { recursive: true }); + await fs.mkdir(path.join(source, 'skills/review/assets'), { recursive: true }); + await fs.mkdir(path.join(source, 'agents'), { recursive: true }); + await fs.writeFile(path.join(source, '.claude-plugin/plugin.json'), JSON.stringify({ + name: 'release-tools', + version: '2.3.4+build.1', + description: 'Release workflow tools.', + displayName: 'Release Tools', + author: { name: 'TokenRoll', email: 'maintainers@example.com', url: 'https://example.com/team' }, + homepage: 'https://example.com/release-tools', + repository: 'https://github.com/TokenRollAI/release-tools', + license: 'MIT', + keywords: ['release', 'review'], + })); + await fs.writeFile(path.join(source, 'commands/release.md'), `--- +description: Prepare a release. +argument-hint: +argumentHint: +allowed-tools: Read, Grep +model: sonnet +--- +Prepare release $ARGUMENTS. +`); + await fs.writeFile(path.join(source, 'commands/status.md'), `--- +description: Check release status. +argument-hint: +argumentHint: +--- +Check status for $ARGUMENTS. +`); + await fs.writeFile(path.join(source, 'skills/review/SKILL.md'), `--- +description: Review a change. +user-invocable: false +disable-model-invocation: false +allowed-tools: Read, Grep +context: fork +agent: reviewer +--- +Review the change. +`); + /** 包含无效 UTF-8 和零字节的 Skill 辅助文件。 */ + const binary = Buffer.from([0, 255, 1, 128, 10]); + await fs.writeFile(path.join(source, 'skills/review/assets/logo.bin'), binary); + await fs.writeFile(path.join(source, 'agents/reviewer.md'), `--- +description: Review code. +tools: Read, NotebookEdit, WebSearch, Bash +disallowedTools: Write +model: sonnet +effort: high +maxTurns: 8 +skills: + - review +memory: project +background: false +isolation: worktree +permissionMode: plan +--- +Review code. +`); + + /** 完整 Plugin 迁移和字段级报告。 */ + const report = await migrate({ cwd: root, source: 'legacy-plugin', destination: 'migrated' }); + /** 迁移后顶层元数据配置源码。 */ + const config = await fs.readFile(path.join(root, 'migrated/acplugin.config.ts'), 'utf8'); + /** 迁移后规范 Command。 */ + const command = await fs.readFile(path.join(root, 'migrated/src/commands/release.md'), 'utf8'); + /** 迁移后规范 Agent。 */ + const agent = await fs.readFile(path.join(root, 'migrated/src/agents/reviewer.md'), 'utf8'); + /** 迁移后按字节复制的 Skill 辅助文件。 */ + const migratedBinary = await fs.readFile(path.join(root, 'migrated/src/skills/review/assets/logo.bin')); + + expect(report.success, JSON.stringify(report.diagnostics)).toBe(true); + expect(config).toContain('version: "2.3.4+build.1"'); + expect(config).toContain('author: {"name":"TokenRoll","email":"maintainers@example.com","url":"https://example.com/team"}'); + expect(command).toContain('argumentHint: '); + expect(command).toContain('allowedTools:'); + expect(command).toContain('Prepare release {{arguments}}.'); + expect(agent).toContain('capabilities:'); + expect(agent).toContain('filesystem:write'); + expect(agent).toContain('search'); + expect(agent).toContain('network'); + expect(agent).toContain('platforms:'); + expect(report.items).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: 'agent', id: 'reviewer', outcome: 'unmapped', + fields: expect.arrayContaining([ + expect.objectContaining({ field: 'description', outcome: 'mapped', destination: 'src/agents/reviewer.md' }), + expect.objectContaining({ field: 'permissionMode', outcome: 'unmapped' }), + ]), + }), + expect.objectContaining({ + kind: 'command', id: 'release', outcome: 'degraded', + fields: expect.arrayContaining([ + expect.objectContaining({ field: 'description', outcome: 'mapped' }), + expect.objectContaining({ field: 'argument-hint', outcome: 'mapped' }), + expect.objectContaining({ field: 'argumentHint', outcome: 'degraded' }), + ]), + }), + expect.objectContaining({ + kind: 'command', id: 'status', outcome: 'migrated', + fields: expect.arrayContaining([ + expect.objectContaining({ field: 'argument-hint', outcome: 'mapped' }), + expect.objectContaining({ field: 'argumentHint', outcome: 'mapped' }), + ]), + }), + expect.objectContaining({ + kind: 'skill', id: 'review', outcome: 'migrated', + fields: expect.arrayContaining([ + expect.objectContaining({ field: 'user-invocable', outcome: 'mapped' }), + expect.objectContaining({ field: 'disable-model-invocation', outcome: 'mapped' }), + ]), + }), + expect.objectContaining({ + kind: 'metadata', id: 'release-tools', outcome: 'migrated', + fields: expect.arrayContaining([ + expect.objectContaining({ field: 'version', outcome: 'mapped' }), + expect.objectContaining({ field: 'author.email', outcome: 'mapped' }), + expect.objectContaining({ field: 'license', outcome: 'mapped' }), + ]), + }), + ])); + expect(createHash('sha256').update(migratedBinary).digest('hex')).toBe(createHash('sha256').update(binary).digest('hex')); + expect(await fs.readFile(path.join(root, 'migrated/.gitignore'), 'utf8')).toContain('.acplugin-migration/unmapped/'); + }); + + it('validates every metadata source field before emitting canonical config', async () => { + /** 非法、规范化和冗余元数据回归使用的临时工作目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-migration-test-')); + roots.push(root); + /** 同时包含根元数据和 Marketplace interface 回退字段的旧 Plugin。 */ + const source = path.join(root, 'legacy-plugin'); + await fs.mkdir(path.join(source, '.claude-plugin'), { recursive: true }); + await fs.mkdir(path.join(source, 'skills/hello'), { recursive: true }); + await fs.writeFile(path.join(source, 'skills/hello/SKILL.md'), '---\ndescription: Hello.\n---\nHello.\n'); + await fs.writeFile(path.join(source, '.claude-plugin/plugin.json'), JSON.stringify({ + name: 'metadata-fixture', + version: '1.0.0+build.1', + description: 'Primary description.', + displayName: ' Metadata Fixture ', + author: { name: ' TokenRoll ', email: 'not-an-email', url: 'not-a-url' }, + homepage: 'not-a-url', + repository: 'git@example.com:owner/repository.git', + license: 'NOT A VALID SPDX EXPRESSION', + keywords: ['release', ' release '], + interface: { + displayName: 'Fallback Display', + shortDescription: 'Fallback short description.', + longDescription: 'Fallback long description.', + developerName: 'Fallback Developer', + websiteURL: 'https://example.com/fallback', + }, + })); + + /** 非严格模式仍生成只包含合法字段的工程,并把所有损失留在报告。 */ + const report = await migrate({ cwd: root, source: 'legacy-plugin', destination: 'migrated' }); + /** 经过逐字段过滤和规范化的最终配置源码。 */ + const config = await fs.readFile(path.join(root, 'migrated/acplugin.config.ts'), 'utf8'); + /** 元数据资源的字段最差结果。 */ + const metadata = report.items.find(item => item.kind === 'metadata'); + + expect(report.success, JSON.stringify(report.diagnostics)).toBe(true); + expect(report.diagnostics).not.toContainEqual(expect.objectContaining({ code: 'MIGRATION_PROJECT_VALIDATION_FAILED' })); + expect(config).toContain('version: "1.0.0+build.1"'); + expect(config).toContain('displayName: "Metadata Fixture"'); + expect(config).toContain('author: {"name":"TokenRoll"}'); + expect(config).toContain('homepage: "https://example.com/fallback"'); + expect(config).toContain('keywords: ["release"]'); + expect(config).not.toContain('repository:'); + expect(config).not.toContain('license:'); + expect(metadata).toMatchObject({ kind: 'metadata', outcome: 'unmapped' }); + expect(metadata?.fields).toEqual(expect.arrayContaining([ + expect.objectContaining({ field: 'version', outcome: 'mapped' }), + expect.objectContaining({ field: 'displayName', outcome: 'degraded' }), + expect.objectContaining({ field: 'interface.displayName', outcome: 'unmapped' }), + expect.objectContaining({ field: 'interface.shortDescription', outcome: 'unmapped' }), + expect.objectContaining({ field: 'interface.longDescription', outcome: 'unmapped' }), + expect.objectContaining({ field: 'author.name', outcome: 'degraded' }), + expect.objectContaining({ field: 'author.email', outcome: 'unmapped' }), + expect.objectContaining({ field: 'author.url', outcome: 'unmapped' }), + expect.objectContaining({ field: 'interface.developerName', outcome: 'unmapped' }), + expect.objectContaining({ field: 'homepage', outcome: 'unmapped' }), + expect.objectContaining({ field: 'interface.websiteURL', outcome: 'degraded' }), + expect.objectContaining({ field: 'repository', outcome: 'unmapped' }), + expect.objectContaining({ field: 'license', outcome: 'unmapped' }), + expect.objectContaining({ field: 'keywords', outcome: 'degraded' }), + ])); + }); + + it('migrates only safe remote HTTPS MCP declarations with the official Extension package', async () => { + /** 安全远程 MCP 测试使用的临时工作目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-migration-test-')); + roots.push(root); + /** 只包含安全远程 MCP 的旧工程。 */ + const source = path.join(root, 'legacy-project'); + await fs.mkdir(path.join(source, '.claude'), { recursive: true }); + await fs.writeFile(path.join(source, '.mcp.json'), JSON.stringify({ + mcpServers: { + docs: { + type: 'http', + url: 'https://mcp.example.com/mcp', + headers: { + 'Authorization': 'Bearer ${DOCS_TOKEN}', + 'X-Tenant': '${TENANT_ID}', + }, + }, + }, + })); + + /** 自动迁移远程声明后的规范工程报告。 */ + const report = await migrate({ + cwd: root, + source: 'legacy-project', + destination: 'migrated', + name: 'remote-mcp', + description: 'Remote MCP migration.', + }); + /** 生成的类型化 MCP 描述源码。 */ + const descriptor = await fs.readFile(path.join(root, 'migrated/src/mcp/docs/mcp.ts'), 'utf8'); + /** 新工程依赖映射。 */ + const manifest = JSON.parse(await fs.readFile(path.join(root, 'migrated/package.json'), 'utf8')) as { + readonly devDependencies: Record; + }; + /** Migration 与 init 共用的、由公开 package manifests 生成的生态版本快照。 */ + const versions = JSON.parse(await fs.readFile( + new URL('../../acplugin/src/ecosystem/versions.json', import.meta.url), + 'utf8', + )) as Record; + + expect(report.success, JSON.stringify(report.diagnostics)).toBe(true); + expect(report.items).toContainEqual(expect.objectContaining({ + kind: 'mcp', + id: 'docs', + outcome: 'migrated', + fields: expect.arrayContaining([ + expect.objectContaining({ field: 'url', source: '.mcp.json', destination: 'src/mcp/docs/mcp.ts', outcome: 'mapped' }), + expect.objectContaining({ field: 'headers.Authorization', outcome: 'mapped' }), + expect.objectContaining({ field: 'headers.X-Tenant', outcome: 'mapped' }), + ]), + })); + expect(descriptor).toContain('from \'@tokenroll/acplugin-extension-mcp\''); + expect(descriptor).toContain('"env":"DOCS_TOKEN"'); + expect(descriptor).toContain('"env": "TENANT_ID"'); + expect(manifest.devDependencies['@tokenroll/acplugin-extension-mcp']) + .toBe(`^${versions['@tokenroll/acplugin-extension-mcp']}`); + await expect(fs.access(path.join(root, 'migrated/node_modules'))).rejects.toThrow(); + }); +}); diff --git a/packages/test/test/platforms/claude-code.test.ts b/packages/test/test/platforms/claude-code.test.ts new file mode 100644 index 0000000..7ccec9e --- /dev/null +++ b/packages/test/test/platforms/claude-code.test.ts @@ -0,0 +1,78 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import claudeCode, { PLATFORM_ID } from '@tokenroll/acplugin-platform-claude-code'; + +/** 跨包契约测试读取源码边界时使用的仓库根目录。 */ +const repositoryRoot = fileURLToPath(new URL('../../../..', import.meta.url)); + +/** + * 递归读取 Claude Code Platform 的全部 TypeScript 源码。 + * + * @param directory 当前需要遍历的源码目录。 + * @returns 按文件名稳定排序并拼接后的源码文本。 + */ +async function platformSources(directory: string): Promise { + /** 当前目录按名称排序后的文件系统项。 */ + const entries = (await fs.readdir(directory, { withFileTypes: true })) + .sort((left, right) => left.name.localeCompare(right.name, 'en')); + /** 当前目录与全部子目录累计的 TypeScript 源码。 */ + const sources: string[] = []; + for (const entry of entries) { + /** 当前目录项的绝对路径。 */ + const target = path.join(directory, entry.name); + if (entry.isDirectory()) + sources.push(await platformSources(target)); + else if (entry.isFile() && entry.name.endsWith('.ts')) + sources.push(await fs.readFile(target, 'utf8')); + } + return sources.join('\n'); +} + +describe('Claude Code public Platform integration', () => { + it('exports an independent Platform factory with a frozen Marketplace contract', () => { + /** 通过独立公开 package 创建的 Claude Code Platform。 */ + const platform = claudeCode({ + strict: false, + defaultEnabled: false, + marketplace: { + owner: { + name: 'TokenRoll', + email: 'maintainers@example.com', + url: 'https://github.com/TokenRollAI', + }, + category: 'Developer Tools', + tags: ['release'], + }, + }); + + expect(platform.id).toBe(PLATFORM_ID); + expect(platform.strict).toBe(false); + expect(platform.deliveryType).toBe('plugin'); + expect(platform.options).toEqual({ + defaultEnabled: false, + marketplace: { + owner: { + name: 'TokenRoll', + email: 'maintainers@example.com', + url: 'https://github.com/TokenRollAI', + }, + category: 'Developer Tools', + tags: ['release'], + }, + }); + expect(Object.isFrozen(platform.options)).toBe(true); + expect(Object.isFrozen(platform.options!.marketplace)).toBe(true); + }); + + it('keeps Hooks and MCP implementation packages outside the Platform dependency boundary', async () => { + /** Claude Code 公开 Platform 的完整源码文本。 */ + const source = await platformSources(path.join(repositoryRoot, 'packages/platforms/claude-code/src')); + + expect(source).not.toContain('@tokenroll/acplugin-extension-hooks'); + expect(source).not.toContain('@tokenroll/acplugin-extension-mcp'); + expect(source).not.toContain('@tokenroll/acplugin-module-hooks'); + expect(source).not.toContain('@tokenroll/acplugin-module-mcp'); + }); +}); diff --git a/packages/test/test/platforms/codex.test.ts b/packages/test/test/platforms/codex.test.ts new file mode 100644 index 0000000..c769999 --- /dev/null +++ b/packages/test/test/platforms/codex.test.ts @@ -0,0 +1,236 @@ +import { spawn } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { BuildReport } from '@tokenroll/acplugin'; +import codex, { PLATFORM_ID } from '@tokenroll/acplugin-platform-codex'; + +/** 跨包契约测试读取源码边界时使用的仓库根目录。 */ +const repositoryRoot = fileURLToPath(new URL('../../../..', import.meta.url)); + +/** 配置和生命周期共用品牌实例的主包真实构建入口。 */ +const acpluginEntry = path.join(repositoryRoot, 'packages/acplugin/dist/index.mjs'); + +/** 临时工程通过正常 package specifier 加载的 Codex Platform 包名。 */ +const codexPackageName = '@tokenroll/acplugin-platform-codex'; + +/** 当前测试创建并在 afterEach 中删除的临时工程。 */ +const temporaryRoots: string[] = []; + +/** + * 在原生 Node ESM 子进程中运行已构建主包。 + * + * @param root 包含真实配置文件的临时项目根。 + * @returns 公开 runProject 产生的结构化结果。 + */ +async function runBuiltProject(root: string): Promise { + /** 子进程加载公开入口并返回稳定 JSON 的 ESM 源码。 */ + const source = ` +import { runProject } from ${JSON.stringify(acpluginEntry)}; +try { + const result = await runProject(${JSON.stringify({ cwd: root, command: 'build', mode: 'production' })}); + process.stdout.write(JSON.stringify({ ok: true, result })); +} catch (error) { + process.stdout.write(JSON.stringify({ + ok: false, + message: error instanceof Error ? error.message : 'Project execution failed.', + })); +} +`; + /** 不经过 Vitest alias 的原生 ESM 执行结果。 */ + const execution = await new Promise<{ readonly code: number | null; readonly stdout: string; readonly stderr: string }>((resolve, reject) => { + /** 与真实 CLI 相同模块边界的 Node 子进程。 */ + const child = spawn(process.execPath, ['--input-type=module', '--eval', source], { + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + /** 子进程累计的 JSON 标准输出。 */ + let stdout = ''; + /** 子进程累计的错误输出。 */ + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => stdout += chunk); + child.stderr.on('data', (chunk: string) => stderr += chunk); + child.once('error', reject); + child.once('close', code => resolve({ code, stdout, stderr })); + }); + if (execution.code !== 0) + throw new Error(`Project subprocess failed: ${execution.stderr}`); + /** 子进程返回的成功结果或安全错误摘要。 */ + const payload = JSON.parse(execution.stdout) as { readonly ok: boolean; readonly result?: BuildReport; readonly message?: string }; + if (!payload.ok || payload.result === undefined) + throw new Error(payload.message ?? 'Project execution failed.'); + return payload.result; +} + +/** + * 用 package-manager 风格目录链接给临时工程安装真实构建后的 Codex 包。 + * + * @param root 临时消费工程根。 + */ +async function installBuiltCodex(root: string): Promise { + /** scope 目录必须先存在,最终 package link 才与 pnpm 布局语义一致。 */ + const scope = path.join(root, 'node_modules/@tokenroll'); + await fs.mkdir(scope, { recursive: true }); + await fs.symlink( + path.join(repositoryRoot, 'packages/platforms/codex'), + path.join(scope, 'acplugin-platform-codex'), + 'dir', + ); +} + +/** + * 递归读取 Codex Platform 的全部 TypeScript 源码。 + * + * @param directory 当前需要遍历的源码目录。 + * @returns 按文件名稳定排序并拼接后的源码文本。 + */ +async function platformSources(directory: string): Promise { + /** 当前目录按名称排序后的文件系统项。 */ + const entries = (await fs.readdir(directory, { withFileTypes: true })) + .sort((left, right) => left.name.localeCompare(right.name, 'en')); + /** 当前目录与全部子目录累计的 TypeScript 源码。 */ + const sources: string[] = []; + for (const entry of entries) { + /** 当前目录项的绝对路径。 */ + const target = path.join(directory, entry.name); + if (entry.isDirectory()) + sources.push(await platformSources(target)); + else if (entry.isFile() && entry.name.endsWith('.ts')) + sources.push(await fs.readFile(target, 'utf8')); + } + return sources.join('\n'); +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('Codex public Platform integration', () => { + it('exports an independent Platform factory with typed interface and Marketplace policy', () => { + /** 通过独立公开 package 创建的 Codex Platform。 */ + const platform = codex({ + strict: false, + interface: { + category: 'Developer Tools', + capabilities: ['Review changes'], + defaultPrompt: 'Review this change.', + }, + marketplace: { + displayName: 'TokenRoll Plugins', + policy: { installation: 'INSTALLED_BY_DEFAULT' }, + }, + }); + + expect(platform.id).toBe(PLATFORM_ID); + expect(platform.strict).toBe(false); + expect(platform.deliveryType).toBe('plugin'); + expect(platform.options).toEqual({ + interface: { + category: 'Developer Tools', + capabilities: ['Review changes'], + defaultPrompt: 'Review this change.', + }, + marketplace: { + displayName: 'TokenRoll Plugins', + policy: { installation: 'INSTALLED_BY_DEFAULT' }, + }, + }); + expect(Object.isFrozen(platform.options)).toBe(true); + expect(Object.isFrozen(platform.options!.interface)).toBe(true); + expect(Object.isFrozen(platform.options!.marketplace)).toBe(true); + }); + + it('loads plugin-prefixed Command Skill IDs through the built public packages', async () => { + /** 使用真实配置加载路径验证独立 Platform package 与主包 bundle 的品牌一致性。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-codex-generated-id-')); + temporaryRoots.push(root); + await installBuiltCodex(root); + await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/commands/bootstrap.md'), `--- +description: Bootstrap the repository. +--- +Bootstrap the repository. +`); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), ` +import codex from ${JSON.stringify(codexPackageName)}; +export default { + name: 'repository-ops', + version: '1.0.0', + description: 'Repository operations.', + platforms: [codex()], +}; +`); + /** 子进程加载真实 dist 入口并完成事务提交。 */ + const result = await runBuiltProject(root); + /** 最终 Skill ID 不依赖生成后重命名或报告修补。 */ + const generatedId = 'repository-ops-bootstrap'; + + expect(result.success, JSON.stringify(result.diagnostics)).toBe(true); + expect(result.packages.find(unit => unit.id === 'plugin')?.assets) + .toContainEqual(expect.objectContaining({ path: `skills/${generatedId}/SKILL.md` })); + expect(result.compatibility).toContainEqual(expect.objectContaining({ + subject: 'command:bootstrap', + transformation: `explicit-skill:${generatedId}`, + })); + expect(await fs.readFile(path.join(root, `dist/codex/plugin/skills/${generatedId}/SKILL.md`), 'utf8')) + .toContain(`name: ${generatedId}`); + }); + + it('keeps Hooks and MCP implementation packages outside the Platform dependency boundary', async () => { + /** Codex 公开 Platform 的完整源码文本。 */ + const source = await platformSources(path.join(repositoryRoot, 'packages/platforms/codex/src')); + + expect(source).not.toContain('@tokenroll/acplugin-extension-hooks'); + expect(source).not.toContain('@tokenroll/acplugin-extension-mcp'); + expect(source).not.toContain('@tokenroll/acplugin-module-hooks'); + expect(source).not.toContain('@tokenroll/acplugin-module-mcp'); + }); + + it('reports an independent arguments transformation only when the Command uses the placeholder', async () => { + /** 覆盖有参数和无参数 Command 的真实 Scanner/Lifecycle 工程。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-codex-arguments-')); + temporaryRoots.push(root); + await installBuiltCodex(root); + await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/commands/deploy.md'), `--- +description: Deploy an environment. +--- +Deploy {{arguments}}. +`); + await fs.writeFile(path.join(root, 'src/commands/status.md'), `--- +description: Show deployment status. +--- +Show deployment status. +`); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), ` +import codex from ${JSON.stringify(codexPackageName)}; +export default { + name: 'codex-arguments', + version: '1.0.0', + description: 'Verify Codex argument compatibility.', + platforms: [codex({ strict: false })], +}; +`); + /** 执行真实配置加载、扫描、转换、候选校验和事务后的结果。 */ + const result = await runBuiltProject(root); + + expect(result.success, JSON.stringify(result.diagnostics)).toBe(true); + expect(result.compatibility).toContainEqual(expect.objectContaining({ + platform: 'codex', + subject: 'command:deploy', + capability: 'arguments', + level: 'transform', + })); + expect(result.compatibility).not.toContainEqual(expect.objectContaining({ + platform: 'codex', + subject: 'command:status', + capability: 'arguments', + })); + expect(await fs.readFile(path.join(root, 'dist/codex/plugin/skills/codex-arguments-deploy/SKILL.md'), 'utf8')) + .toContain('the arguments supplied with this explicit invocation'); + }); +}); diff --git a/packages/test/test/platforms/native-component-contribution.test.ts b/packages/test/test/platforms/native-component-contribution.test.ts new file mode 100644 index 0000000..2fc2cda --- /dev/null +++ b/packages/test/test/platforms/native-component-contribution.test.ts @@ -0,0 +1,354 @@ +import { spawn } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { BuildReport, RunProjectOptions } from '@tokenroll/acplugin'; + +/** 当前测试文件所在仓库的绝对根目录。 */ +const repositoryRoot = fileURLToPath(new URL('../../../..', import.meta.url)); + +/** 配置加载使用主包及每个官方 Platform 的真实构建产物。 */ +const entries = Object.freeze({ + 'framework': path.join(repositoryRoot, 'packages/acplugin/dist/index.mjs'), + 'claude-code': path.join(repositoryRoot, 'packages/platforms/claude-code/dist/index.mjs'), + 'cursor': path.join(repositoryRoot, 'packages/platforms/cursor/dist/index.mjs'), + 'opencode': path.join(repositoryRoot, 'packages/platforms/opencode/dist/index.mjs'), + 'codex': path.join(repositoryRoot, 'packages/platforms/codex/dist/index.mjs'), + 'antigravity': path.join(repositoryRoot, 'packages/platforms/antigravity/dist/index.mjs'), + 'pi': path.join(repositoryRoot, 'packages/platforms/pi/dist/index.mjs'), +}); + +/** 当前用例创建并在 afterEach 中统一清理的临时工程。 */ +const temporaryRoots: string[] = []; + +/** 在原生 Node ESM 子进程中运行真实主包,避免 Vitest alias 掩盖 package 边界。 */ +async function runBuiltProject(options: RunProjectOptions): Promise { + const source = ` +import { runProject } from ${JSON.stringify(entries.framework)}; +try { + process.stdout.write(JSON.stringify({ ok: true, result: await runProject(${JSON.stringify(options)}) })); +} catch (error) { + process.stdout.write(JSON.stringify({ + ok: false, + name: error instanceof Error ? error.name : 'Error', + message: error instanceof Error ? error.message : 'Project execution failed.', + cause: error instanceof Error && error.cause instanceof Error ? error.cause.message : undefined, + diagnostics: error && typeof error === 'object' ? Reflect.get(error, 'diagnostics') : undefined, + })); +} +`; + const execution = await new Promise<{ readonly code: number | null; readonly stdout: string; readonly stderr: string }>((resolve, reject) => { + const child = spawn(process.execPath, ['--input-type=module', '--eval', source], { + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => stdout += chunk); + child.stderr.on('data', (chunk: string) => stderr += chunk); + child.once('error', reject); + child.once('close', code => resolve({ code, stdout, stderr })); + }); + if (execution.code !== 0) + throw new Error(`Project subprocess failed: ${execution.stderr}`); + const payload = JSON.parse(execution.stdout) as { + readonly ok: boolean; + readonly result?: BuildReport; + readonly name?: string; + readonly message?: string; + readonly cause?: string; + readonly diagnostics?: unknown; + }; + if (!payload.ok || payload.result === undefined) + throw new Error(`${payload.name ?? 'Error'}: ${payload.message ?? 'Project execution failed.'} ${payload.cause ?? ''} ${JSON.stringify(payload.diagnostics ?? [])}`); + return payload.result; +} + +interface BuiltDevEvent { + readonly type: 'initial' | 'complete'; + readonly success: boolean; + readonly committed?: boolean; +} + +/** 启动真实构建产物的 DevSession,并按行读取稳定的轮次摘要。 */ +function startBuiltDevProject(cwd: string, platform: string): { + readonly child: ReturnType; + readonly next: () => Promise; +} { + const source = ` +import { createProject } from ${JSON.stringify(entries.framework)}; +const session = await createProject({ cwd: ${JSON.stringify(cwd)} }).dev({ mode: 'development', platforms: [${JSON.stringify(platform)}] }); +process.stdout.write(JSON.stringify({ type: 'initial', success: session.current.success }) + '\\n'); +session.subscribe(event => { + if (event.type === 'build-complete') + process.stdout.write(JSON.stringify({ type: 'complete', success: event.report.success, committed: event.report.committed }) + '\\n'); +}); +process.stdin.resume(); +process.stdin.on('end', async () => { + await session.close(); +}); +`; + const child = spawn(process.execPath, ['--input-type=module', '--eval', source], { + cwd, + env: process.env, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let buffer = ''; + const queue: BuiltDevEvent[] = []; + const waiters: ((event: BuiltDevEvent) => void)[] = []; + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + buffer += chunk; + for (;;) { + const newline = buffer.indexOf('\n'); + if (newline < 0) + break; + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + if (line.length === 0) + continue; + const event = JSON.parse(line) as BuiltDevEvent; + const waiter = waiters.shift(); + if (waiter === undefined) + queue.push(event); + else + waiter(event); + } + }); + return { + child, + next: () => { + const queued = queue.shift(); + if (queued !== undefined) + return Promise.resolve(queued); + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + const index = waiters.indexOf(resolve); + if (index >= 0) + waiters.splice(index, 1); + reject(new Error('Timed out waiting for built DevSession event.')); + }, 15_000); + waiters.push((event) => { + clearTimeout(timeout); + resolve(event); + }); + }); + }, + }; +} + +/** 在临时工程写入模拟正常包管理器安装的 ESM package 代理。 */ +async function writePackageProxy(root: string, packageName: string, entry: string): Promise { + const packageRoot = path.join(root, 'node_modules', ...packageName.split('/')); + await fs.mkdir(packageRoot, { recursive: true }); + await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ + name: packageName, + version: '1.0.0', + type: 'module', + exports: packageName === '@tokenroll/acplugin' + ? { '.': './index.mjs', './sdk': './sdk.mjs' } + : './index.mjs', + })); + const sourceRoot = path.dirname(entry); + for (const file of (await fs.readdir(sourceRoot)).filter(file => file.endsWith('.mjs'))) + await fs.copyFile(path.join(sourceRoot, file), path.join(packageRoot, file)); + await fs.copyFile(entry, path.join(packageRoot, 'index.mjs')); +} + +/** Codex 的生产 bundle 仍通过正常 runtime dependencies 解析其官方 wire codec。 */ +async function linkCodexRuntimeDependencies(root: string): Promise { + for (const dependency of ['image-size', 'saxes', 'yaml']) { + const source = await fs.realpath(path.join(repositoryRoot, 'packages/platforms/codex/node_modules', dependency)); + await fs.symlink(source, path.join(root, 'node_modules', dependency), 'dir'); + } +} + +/** 主包 bundle external 的正常 runtime dependencies 必须同样出现在代理 consumer 中。 */ +async function linkFrameworkRuntimeDependencies(root: string): Promise { + for (const dependency of ['@inquirer/prompts', 'commander', 'gray-matter', 'rolldown', 'semver', 'spdx-expression-parse']) { + const source = await fs.realpath(path.join(repositoryRoot, 'packages/acplugin/node_modules', dependency)); + await fs.mkdir(path.dirname(path.join(root, 'node_modules', dependency)), { recursive: true }); + await fs.symlink(source, path.join(root, 'node_modules', dependency), 'dir'); + } +} + +/** 建立一个只含私有 Platform Component contribution 的领域中立消费工程。 */ +async function createProject(): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-native-component-contribution-')); + temporaryRoots.push(root); + await writePackageProxy(root, '@tokenroll/acplugin', entries.framework); + await linkFrameworkRuntimeDependencies(root); + await writePackageProxy(root, '@tokenroll/acplugin-platform-claude-code', entries['claude-code']); + await writePackageProxy(root, '@tokenroll/acplugin-platform-cursor', entries.cursor); + await writePackageProxy(root, '@tokenroll/acplugin-platform-opencode', entries.opencode); + await writePackageProxy(root, '@tokenroll/acplugin-platform-codex', entries.codex); + await writePackageProxy(root, '@tokenroll/acplugin-platform-antigravity', entries.antigravity); + await writePackageProxy(root, '@tokenroll/acplugin-platform-pi', entries.pi); + await linkCodexRuntimeDependencies(root); + await fs.mkdir(path.join(root, 'src/skills/base'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/skills/base/SKILL.md'), '---\ndescription: Base fixture Skill.\n---\nBase.\n'); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), ` +import { defineConfig } from '@tokenroll/acplugin'; +import { defineExtension } from '@tokenroll/acplugin/sdk'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import cursor from '@tokenroll/acplugin-platform-cursor'; +import openCode from '@tokenroll/acplugin-platform-opencode'; +import codex from '@tokenroll/acplugin-platform-codex'; +import antigravity from '@tokenroll/acplugin-platform-antigravity'; +import pi from '@tokenroll/acplugin-platform-pi'; + +const subject = 'fixture:private-agent'; +const compatibility = () => [{ + subject, + capability: 'delivery', + level: 'native' as const, + reason: 'The fixture uses a Platform-native private component.', +}] as const; +const extension = defineExtension({ + id: 'private-agent-fixture', + apiVersion: '1', + resourceRoots: [], + createSession: () => ({ + discover: () => ({}), + validate: (_context, state) => ({ state, subjects: [{ subject, capabilities: ['delivery'] }] }), + build: (_context, state) => ({ state }), + contributors: [ + { + platform: 'claude-code', + platformApiVersion: '1', + contribute: () => ({ components: [{ subject, value: { + kind: 'native-agent', id: 'observer', description: 'Observe the workspace.', body: 'Observe.', model: 'capable', tools: ['Read'], + } }], compatibility: compatibility() }), + }, + { + platform: 'cursor', + platformApiVersion: '1', + contribute: () => ({ components: [{ subject, value: { + kind: 'native-agent', id: 'observer', description: 'Observe the workspace.', body: 'Observe.', readonly: true, + } }], compatibility: compatibility() }), + }, + { + platform: 'opencode', + platformApiVersion: '1', + contribute: () => ({ components: [{ subject, value: { + kind: 'native-agent', id: 'observer', description: 'Observe the workspace.', body: 'Observe.', + tools: { read: true, glob: true }, permission: { edit: 'deny', bash: 'deny' }, + } }], compatibility: compatibility() }), + }, + ...['codex', 'antigravity', 'pi'].map(platform => ({ + platform, + platformApiVersion: '1' as const, + contribute: () => ({ components: [{ subject, value: { + kind: 'native-agent', id: 'observer', description: 'Observe the workspace.', body: 'Observe.', + } }], compatibility: compatibility() }), + })), + ], + }), +}); + +export default defineConfig({ + name: 'native-component-fixture', + version: '1.0.0', + description: 'Private Platform component fixture.', + platforms: [claudeCode(), cursor(), openCode(), codex(), antigravity(), pi()], + extensions: [extension], +}); +`); + return root; +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('Platform Component contributions through built public packages', () => { + it.each([ + ['claude-code', 'plugin', 'agents/observer.md'], + ['cursor', 'plugin', 'agents/observer.md'], + ['opencode', 'workspace', '.opencode/agents/observer.md'], + ] as const)('delivers a Platform-owned private Agent natively for %s', async (platform, packageId, assetPath) => { + const root = await createProject(); + const validated = await runBuiltProject({ cwd: root, command: 'validate', mode: 'production', platforms: [platform] }); + const first = await runBuiltProject({ cwd: root, command: 'build', mode: 'production', platforms: [platform] }); + const second = await runBuiltProject({ cwd: root, command: 'build', mode: 'production', platforms: [platform] }); + const unit = first.packages.find(candidate => candidate.id === packageId)!; + const asset = unit.assets.find(candidate => candidate.path === assetPath)!; + + expect(validated).toMatchObject({ success: true, committed: false }); + expect(first.success, JSON.stringify(first.diagnostics, null, 2)).toBe(true); + expect(first.schemaVersion).toBe(3); + expect(first.compatibility).toContainEqual(expect.objectContaining({ + platform, subject: 'fixture:private-agent', capability: 'delivery', level: 'native', + })); + expect(asset).toMatchObject({ + owner: `platform:${platform}`, + origin: { contributors: [{ owner: 'extension:private-agent-fixture', subject: 'fixture:private-agent' }] }, + }); + expect(second.packages).toEqual(first.packages); + await expect(fs.readFile(path.join(root, 'dist', platform, packageId, ...assetPath.split('/')), 'utf8')).resolves.toContain('Observe.'); + }); + + it('supports inspect without committing output and preserves the last output after a failed build', async () => { + const root = await createProject(); + const first = await runBuiltProject({ cwd: root, command: 'build', mode: 'production', platforms: ['claude-code'] }); + const generated = path.join(root, 'dist', 'claude-code', 'plugin', 'skills/base/SKILL.md'); + const contributed = path.join(root, 'dist', 'claude-code', 'plugin', 'agents/observer.md'); + const initial = await fs.readFile(generated); + const initialContribution = await fs.readFile(contributed); + + const inspected = await runBuiltProject({ cwd: root, command: 'inspect', mode: 'production', platforms: ['claude-code'] }); + expect(first.success).toBe(true); + expect(inspected.success).toBe(true); + expect(inspected.committed).toBe(false); + expect(await fs.readFile(generated)).toEqual(initial); + expect(await fs.readFile(contributed)).toEqual(initialContribution); + + await fs.writeFile(path.join(root, 'src/skills/base/SKILL.md'), 'invalid without frontmatter\n'); + const failed = await runBuiltProject({ cwd: root, command: 'build', mode: 'production', platforms: ['claude-code'] }); + expect(failed.success).toBe(false); + expect(await fs.readFile(generated)).toEqual(initial); + expect(await fs.readFile(contributed)).toEqual(initialContribution); + + await fs.writeFile(path.join(root, 'src/skills/base/SKILL.md'), '---\ndescription: Base fixture Skill.\n---\nRecovered.\n'); + const recovered = await runBuiltProject({ cwd: root, command: 'build', mode: 'production', platforms: ['claude-code'] }); + expect(recovered.success).toBe(true); + await expect(fs.readFile(generated, 'utf8')).resolves.toContain('Recovered.'); + }); + + it('rebuilds Platform Component delivery through the public DevSession contract', async () => { + const root = await createProject(); + const running = startBuiltDevProject(root, 'claude-code'); + const initial = await running.next(); + expect(initial).toEqual({ type: 'initial', success: true }); + + const source = path.join(root, 'src/skills/base/SKILL.md'); + await fs.writeFile(source, '---\ndescription: Base fixture Skill.\n---\nDev rebuild.\n'); + const complete = await running.next(); + expect(complete).toEqual({ type: 'complete', success: true, committed: true }); + await expect(fs.readFile(path.join(root, 'dist/claude-code/plugin/skills/base/SKILL.md'), 'utf8')).resolves.toContain('Dev rebuild.'); + await expect(fs.readFile(path.join(root, 'dist/claude-code/plugin/agents/observer.md'), 'utf8')).resolves.toContain('Observe.'); + + running.child.stdin?.end(); + await new Promise((resolve, reject) => { + running.child.once('error', reject); + running.child.once('close', () => resolve()); + }); + }, 20_000); + + it.each(['codex', 'antigravity', 'pi'] as const)('rejects a non-empty private Component contribution for unsupported %s', async (platform) => { + const root = await createProject(); + const report = await runBuiltProject({ cwd: root, command: 'build', mode: 'production', platforms: [platform] }); + + expect(report.success).toBe(false); + expect(report.committed).toBe(false); + expect(report.packages).toEqual([]); + expect(report.diagnostics).toContainEqual(expect.objectContaining({ + code: `${platform.toUpperCase()}_COMPONENT_CONTRIBUTION_UNSUPPORTED`, phase: 'finalize', platform, + })); + await expect(fs.access(path.join(root, 'dist', platform))).rejects.toThrow(); + }); +}); diff --git a/packages/test/test/platforms/secondary-platforms.test.ts b/packages/test/test/platforms/secondary-platforms.test.ts new file mode 100644 index 0000000..e28e9ee --- /dev/null +++ b/packages/test/test/platforms/secondary-platforms.test.ts @@ -0,0 +1,513 @@ +import { spawn } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { BuildReport, RunProjectOptions } from '@tokenroll/acplugin'; + +/** 当前测试文件所在仓库的绝对根目录。 */ +const repositoryRoot = fileURLToPath(new URL('../../../..', import.meta.url)); + +/** 配置文件直接导入的主包真实构建产物。 */ +const acpluginEntry = path.join(repositoryRoot, 'packages/acplugin/dist/index.mjs'); + +/** 六个官方 Platform 的真实独立构建入口。 */ +const platformEntries = { + 'antigravity': path.join(repositoryRoot, 'packages/platforms/antigravity/dist/index.mjs'), + 'claude-code': path.join(repositoryRoot, 'packages/platforms/claude-code/dist/index.mjs'), + 'codex': path.join(repositoryRoot, 'packages/platforms/codex/dist/index.mjs'), + 'cursor': path.join(repositoryRoot, 'packages/platforms/cursor/dist/index.mjs'), + 'opencode': path.join(repositoryRoot, 'packages/platforms/opencode/dist/index.mjs'), + 'pi': path.join(repositoryRoot, 'packages/platforms/pi/dist/index.mjs'), +} as const; + +/** 临时包代理加载的 Hooks Extension 真实构建产物。 */ +const hooksEntry = path.join(repositoryRoot, 'packages/extensions/hooks/dist/index.mjs'); + +/** 临时包代理加载的 MCP Extension 真实构建产物。 */ +const mcpEntry = path.join(repositoryRoot, 'packages/extensions/mcp/dist/index.mjs'); + +/** 当前测试创建并在 afterEach 中统一删除的临时工程。 */ +const temporaryRoots: string[] = []; + +/** + * 在原生 Node ESM 子进程中运行真实主包,确保配置和 Pipeline 共用品牌实例。 + * + * @param options 可 JSON 序列化的项目运行选项。 + * @returns 公开 API 产生的结构化 BuildReport。 + */ +async function runProject(options: RunProjectOptions): Promise { + /** 子进程直接导入真实主包构建产物并序列化结果的 ESM 源码。 */ + const source = ` +import { runProject } from ${JSON.stringify(acpluginEntry)}; +try { + const result = await runProject(${JSON.stringify(options)}); + process.stdout.write(JSON.stringify({ ok: true, result })); +} catch (error) { + process.stdout.write(JSON.stringify({ + ok: false, + name: error instanceof Error ? error.name : 'Error', + message: error instanceof Error ? error.message : 'Project execution failed.', + cause: error instanceof Error && error.cause instanceof Error ? error.cause.message : undefined, + diagnostics: error && typeof error === 'object' ? Reflect.get(error, 'diagnostics') : undefined, + })); +} +`; + /** 原生 ESM 子进程的退出状态和输出。 */ + const execution = await new Promise<{ readonly code: number | null; readonly stdout: string; readonly stderr: string }>((resolve, reject) => { + /** 不经过 Vitest 转换器的真实 Node 进程。 */ + const child = spawn(process.execPath, ['--input-type=module', '--eval', source], { + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + /** 子进程累计的 JSON 标准输出。 */ + let stdout = ''; + /** 子进程累计的框架错误输出。 */ + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + child.on('error', reject); + child.on('close', code => resolve({ code, stdout, stderr })); + }); + if (execution.code !== 0) + throw new Error(`Project subprocess failed: ${execution.stderr}`); + /** 子进程返回的成功结果或安全错误摘要。 */ + const payload = JSON.parse(execution.stdout) as { + readonly ok: boolean; + readonly result?: BuildReport; + readonly name?: string; + readonly message?: string; + readonly diagnostics?: readonly { readonly code?: string; readonly message?: string }[]; + readonly cause?: string; + }; + if (!payload.ok || payload.result === undefined) + throw new Error(`${payload.name ?? 'Error'}: ${payload.message ?? 'Project execution failed.'} ${payload.cause ?? ''} ${JSON.stringify(payload.diagnostics ?? [])}`); + return payload.result; +} + +/** + * 在临时工程中创建一个指向 workspace 构建产物的 ESM 包代理。 + * + * @param root 临时工程根目录。 + * @param packageName 待创建的包名。 + * @param entry workspace 内真实 ESM 入口。 + */ +async function writePackageProxy(root: string, packageName: string, entry: string): Promise { + /** scope/name 转换后的临时 node_modules 包目录。 */ + const packageRoot = path.join(root, 'node_modules', ...packageName.split('/')); + await fs.mkdir(packageRoot, { recursive: true }); + await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ + name: packageName, + version: '1.0.0', + type: 'module', + exports: packageName === '@tokenroll/acplugin' + ? { '.': './index.mjs', './sdk': './sdk.mjs' } + : './index.mjs', + })); + /** 复制真实入口及其同目录 chunks,保持包代理的相对模块图完整。 */ + const sourceRoot = path.dirname(entry); + /** 入口目录内的全部 ESM 构建文件。 */ + const files = await fs.readdir(sourceRoot); + for (const file of files.filter(file => file.endsWith('.mjs'))) { + await fs.copyFile(path.join(sourceRoot, file), path.join(packageRoot, file)); + } + await fs.copyFile(entry, path.join(packageRoot, 'index.mjs')); +} + +/** 将公开 Platform 的真实 runtime dependency 暴露给临时 packed-consumer 代理。 */ +async function linkCodexRuntimeDependencies(root: string): Promise { + /** dependency 是 Codex tarball 正常安装时由包管理器提供的运行时包。 */ + for (const dependency of ['image-size', 'saxes', 'yaml']) { + /** source 解析 pnpm workspace symlink 后的真实 package 根。 */ + const source = await fs.realpath(path.join(repositoryRoot, 'packages/platforms/codex/node_modules', dependency)); + /** destination 模拟消费者 node_modules 的正常依赖布局。 */ + const destination = path.join(root, 'node_modules', dependency); + await fs.symlink(source, destination, 'dir'); + } +} + +/** 读取一个托管输出目录的完整相对路径与字节快照。 */ +async function snapshotDirectory(root: string): Promise>> { + /** files 使用 base64 保留 mode 之外的精确文件字节。 */ + const files: Record = {}; + /** visit 递归枚举受事务管理的普通目录。 */ + async function visit(directory: string): Promise { + /** entries 采用稳定 code-unit 顺序,避免文件系统枚举差异。 */ + const entries = (await fs.readdir(directory, { withFileTypes: true })) + .sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0); + for (const entry of entries) { + /** absolute 是当前候选内已知子路径。 */ + const absolute = path.join(directory, entry.name); + if (entry.isDirectory()) + await visit(absolute); + else if (entry.isFile()) + files[path.relative(root, absolute).split(path.sep).join('/')] = (await fs.readFile(absolute)).toString('base64'); + } + } + await visit(root); + return Object.freeze(files); +} + +/** + * 创建覆盖四个平台、两个 Extension 和所有 Component 的真实工程。 + * + * @returns 已登记自动清理的工程根目录。 + */ +async function createCompleteProject(options: { readonly strictCursor?: boolean; readonly strictPi?: boolean } = {}): Promise { + /** 当前用例独占的临时工程。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-secondary-platforms-')); + temporaryRoots.push(root); + await writePackageProxy(root, '@tokenroll/acplugin', acpluginEntry); + await writePackageProxy(root, '@tokenroll/acplugin-platform-antigravity', platformEntries.antigravity); + await writePackageProxy(root, '@tokenroll/acplugin-platform-claude-code', platformEntries['claude-code']); + await writePackageProxy(root, '@tokenroll/acplugin-platform-codex', platformEntries.codex); + await linkCodexRuntimeDependencies(root); + await writePackageProxy(root, '@tokenroll/acplugin-platform-cursor', platformEntries.cursor); + await writePackageProxy(root, '@tokenroll/acplugin-platform-opencode', platformEntries.opencode); + await writePackageProxy(root, '@tokenroll/acplugin-platform-pi', platformEntries.pi); + await writePackageProxy(root, '@tokenroll/acplugin-extension-hooks', hooksEntry); + await writePackageProxy(root, '@tokenroll/acplugin-extension-mcp', mcpEntry); + await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/skills/review/references'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/agents'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/hooks/session-start'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/hooks/permission'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/mcp/docs'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/mcp/oauth-docs'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/mcp/local-tools'), { recursive: true }); + await fs.mkdir(path.join(root, 'public/assets'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/commands/release.md'), `--- +description: Prepare a release. +argumentHint: +--- +Prepare release {{arguments}}. +`); + await fs.writeFile(path.join(root, 'src/skills/review/SKILL.md'), `--- +description: Review the current change. +invocation: + user: false + model: true +--- +Review the implementation. +`); + await fs.writeFile(path.join(root, 'src/skills/review/references/checklist.md'), 'Review checklist.\n'); + await fs.writeFile(path.join(root, 'src/agents/reviewer.md'), `--- +description: Review code changes. +model: capable +capabilities: + - filesystem:read + - filesystem:write + - search + - shell +--- +Review code and report findings. +`); + await fs.writeFile(path.join(root, 'src/hooks/session-start/hook.ts'), ` +import type { Hook } from '@tokenroll/acplugin-extension-hooks'; +export default { event: 'SessionStart', run() { return { additionalContext: 'Ready.' }; } } satisfies Hook<'SessionStart'>; +`); + await fs.writeFile(path.join(root, 'src/hooks/permission/hook.ts'), ` +import type { Hook } from '@tokenroll/acplugin-extension-hooks'; +export default { event: 'PermissionRequest', run() { return { decision: 'defer' }; } } satisfies Hook<'PermissionRequest'>; +`); + await fs.writeFile(path.join(root, 'src/mcp/docs/mcp.ts'), ` +import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; +export default { + transport: 'http', + url: 'https://mcp.example.com/mcp', + auth: { type: 'bearer', env: 'DOCS_TOKEN' }, +} satisfies McpServer; +`); + await fs.writeFile(path.join(root, 'src/mcp/oauth-docs/mcp.ts'), ` +import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; +export default { + transport: 'http', + url: 'https://oauth-mcp.example.com/mcp', + auth: { type: 'oauth', scopes: ['docs:read', 'docs:write'] }, + headers: { 'X-Zeta': { value: 'z' }, 'X-Alpha': { value: 'a' } }, +} satisfies McpServer; +`); + await fs.writeFile(path.join(root, 'src/mcp/local-tools/mcp.ts'), ` +import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; +export default { transport: 'stdio' } satisfies McpServer; +`); + await fs.writeFile(path.join(root, 'src/mcp/local-tools/server.ts'), ` +let buffer = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + buffer += chunk; + const lines = buffer.split('\\n'); + buffer = lines.pop() ?? ''; + for (const line of lines.filter(Boolean)) { + const message = JSON.parse(line); + if (message.method === 'initialize') { + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, result: { + protocolVersion: message.params.protocolVersion, + capabilities: { tools: {} }, + serverInfo: { name: 'secondary-fixture', version: '1.0.0' }, + } }) + '\\n'); + } else if (message.method === 'tools/list') { + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, result: { tools: [] } }) + '\\n'); + } + } +}); +`); + await fs.writeFile(path.join(root, 'public/assets/readme.txt'), 'Public asset.\n'); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), ` +import antigravity from '@tokenroll/acplugin-platform-antigravity'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; +import cursor from '@tokenroll/acplugin-platform-cursor'; +import openCode from '@tokenroll/acplugin-platform-opencode'; +import pi from '@tokenroll/acplugin-platform-pi'; +import hooks from '@tokenroll/acplugin-extension-hooks'; +import mcp from '@tokenroll/acplugin-extension-mcp'; +export default { + name: 'portable-tools', + version: '1.2.3', + description: 'Portable tools.', + displayName: 'Portable Tools', + author: { name: 'TokenRoll', email: 'maintainers@example.com', url: 'https://example.com/team' }, + homepage: 'https://example.com/portable-tools', + repository: 'https://github.com/TokenRollAI/portable-tools', + license: 'MIT', + keywords: ['portable'], + platforms: [claudeCode({ strict: false }), codex({ strict: false }), cursor({ strict: ${options.strictCursor === true ? 'true' : 'false'} }), antigravity({ strict: false }), openCode({ strict: false }), pi({ strict: ${options.strictPi === true ? 'true' : 'false'} })], + extensions: [hooks(), mcp()], + build: { strict: false }, +}; +`); + return root; +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('official Platform integration', () => { + it('builds six final candidates with field-level compatibility and deterministic MCP bytes', async () => { + /** 覆盖全部官方 Platform 和 Extension Contributor 的规范工程。 */ + const root = await createCompleteProject(); + /** 宽松模式允许矩阵明确声明的有限支持进入交付。 */ + const result = await runProject({ cwd: root, command: 'build', mode: 'production' }); + + expect(result.success, JSON.stringify(result.diagnostics)).toBe(true); + expect(result.packages.map(unit => `${unit.platform}/${unit.id}:${unit.type}`).sort()).toEqual([ + 'antigravity/plugin:plugin', + 'claude-code/plugin:plugin', + 'codex/plugin:plugin', + 'cursor/plugin:plugin', + 'opencode/workspace:workspace', + 'pi/package:package', + ]); + /** Cursor 使用官方 Plugin Manifest、原生 Component 和 remote-only MCP。 */ + const cursorManifest = JSON.parse(await fs.readFile( + path.join(root, 'dist/cursor/plugin/.cursor-plugin/plugin.json'), + 'utf8', + )) as Record; + expect(cursorManifest).toMatchObject({ + name: 'portable-tools', + commands: './commands/*.md', + skills: './skills/*/SKILL.md', + agents: './agents/*.md', + hooks: './hooks/hooks.json', + mcpServers: './mcp.json', + }); + await expect(fs.access(path.join(root, 'dist/cursor/plugin/mcp/local-tools/server.mjs'))).rejects.toThrow(); + + /** Antigravity 保持最小 Manifest,并将 Command/Agent 收敛到 Skills。 */ + expect(JSON.parse(await fs.readFile(path.join(root, 'dist/antigravity/plugin/plugin.json'), 'utf8'))) + .toEqual({ name: 'portable-tools' }); + expect(await fs.readFile( + path.join(root, 'dist/antigravity/plugin/skills/command-release/SKILL.md'), + 'utf8', + )).toContain('the arguments supplied with this explicit invocation'); + expect(await fs.readFile( + path.join(root, 'dist/antigravity/plugin/skills/agent-reviewer/SKILL.md'), + 'utf8', + )).toContain('role guidance'); + + /** OpenCode 输出 workspace 资源、runtime Hook Plugin 与 local/remote MCP 配置。 */ + const openCodeConfig = JSON.parse(await fs.readFile(path.join(root, 'dist/opencode/workspace/opencode.json'), 'utf8')); + expect(openCodeConfig).toHaveProperty('mcp.docs.type', 'remote'); + expect(openCodeConfig).toHaveProperty('mcp.docs.headers.Authorization', 'Bearer {env:DOCS_TOKEN}'); + expect(openCodeConfig).toHaveProperty('mcp.local-tools.type', 'local'); + expect(openCodeConfig).toHaveProperty('mcp.oauth-docs.oauth.scope', 'docs:read docs:write'); + await expect(fs.access(path.join(root, 'dist/opencode/workspace/.opencode/mcp.json'))).rejects.toThrow(); + expect(await fs.readFile( + path.join(root, 'dist/opencode/workspace/.opencode/plugins/acplugin-hooks.mjs'), + 'utf8', + )).toContain('\'tool.execute.before\''); + + /** Pi 输出真正的 npm package,Hooks 进入 Extension,MCP 不产生伪配置。 */ + const piPackage = JSON.parse(await fs.readFile(path.join(root, 'dist/pi/package/package.json'), 'utf8')); + expect(piPackage).toMatchObject({ + name: 'portable-tools', + version: '1.2.3', + pi: { + skills: ['./skills'], + prompts: ['./prompts'], + extensions: ['./extensions/acplugin-hooks.mjs'], + }, + }); + expect(piPackage).not.toHaveProperty('private'); + /** Claude Code 与 Codex 分别使用各自官方 OAuth scope wire。 */ + const claudeMcp = JSON.parse(await fs.readFile(path.join(root, 'dist/claude-code/plugin/.mcp.json'), 'utf8')); + /** Codex MCP 配置保留 scope 数组。 */ + const codexMcp = JSON.parse(await fs.readFile(path.join(root, 'dist/codex/plugin/.mcp.json'), 'utf8')); + expect(claudeMcp).toHaveProperty('mcpServers.oauth-docs.oauth.scopes', 'docs:read docs:write'); + expect(codexMcp).toHaveProperty('oauth-docs.scopes', ['docs:read', 'docs:write']); + /** Cursor 与 Antigravity 使用各自可在运行时求值的 Bearer Header wire。 */ + const cursorMcp = JSON.parse(await fs.readFile(path.join(root, 'dist/cursor/plugin/mcp.json'), 'utf8')); + /** Antigravity MCP 配置保持独立根文件。 */ + const antigravityMcp = JSON.parse(await fs.readFile(path.join(root, 'dist/antigravity/plugin/mcp_config.json'), 'utf8')); + expect(cursorMcp).toHaveProperty('mcpServers.docs.headers.Authorization', 'Bearer ${env:DOCS_TOKEN}'); + expect(antigravityMcp).toHaveProperty('mcpServers.docs.headers.Authorization', 'Bearer ${DOCS_TOKEN}'); + /** 四个平台针对三类 Component 实际提交的完整能力结论。 */ + const componentCompatibility = (platform: string): string[] => result.compatibility + .filter(entry => entry.platform === platform && /^(?:command|skill|agent):/u.test(entry.subject)) + .map(entry => `${entry.subject}/${entry.capability}/${entry.level}`) + .sort(); + expect(componentCompatibility('cursor')).toEqual([ + 'agent:reviewer/agent.capabilities/degraded', + 'agent:reviewer/agent.model/degraded', + 'agent:reviewer/component/native', + 'command:release/argument-hint/degraded', + 'command:release/component/native', + 'skill:review/component/native', + 'skill:review/invocation.user/degraded', + ].sort()); + expect(componentCompatibility('antigravity')).toEqual([ + 'agent:reviewer/agent.capabilities/degraded', + 'agent:reviewer/agent.model/degraded', + 'agent:reviewer/component/degraded', + 'command:release/argument-hint/degraded', + 'command:release/arguments/transform', + 'command:release/component/transform', + 'skill:review/component/native', + 'skill:review/invocation/degraded', + ].sort()); + expect(componentCompatibility('opencode')).toEqual([ + 'agent:reviewer/agent.capabilities/transform', + 'agent:reviewer/agent.model/degraded', + 'agent:reviewer/component/native', + 'command:release/argument-hint/degraded', + 'command:release/component/native', + 'skill:review/component/native', + 'skill:review/invocation/degraded', + ].sort()); + expect(componentCompatibility('pi')).toEqual([ + 'agent:reviewer/agent.capabilities/degraded', + 'agent:reviewer/agent.model/degraded', + 'agent:reviewer/component/degraded', + 'command:release/argument-hint/native', + 'command:release/arguments/native', + 'command:release/component/transform', + 'skill:review/component/native', + 'skill:review/invocation/degraded', + ].sort()); + expect(result.compatibility).toEqual(expect.arrayContaining([ + expect.objectContaining({ platform: 'cursor', subject: 'mcp:local-tools', level: 'unsupported' }), + expect.objectContaining({ platform: 'antigravity', subject: 'command:release', level: 'transform' }), + expect.objectContaining({ platform: 'opencode', subject: 'agent:reviewer', capability: 'agent.capabilities', level: 'transform' }), + expect.objectContaining({ platform: 'pi', subject: 'mcp:docs', level: 'unsupported' }), + expect.objectContaining({ platform: 'claude-code', subject: 'mcp:oauth-docs', capability: 'auth.oauth', level: 'native' }), + expect.objectContaining({ platform: 'codex', subject: 'mcp:oauth-docs', capability: 'auth.oauth', level: 'native' }), + expect.objectContaining({ platform: 'cursor', subject: 'mcp:oauth-docs', capability: 'auth.oauth', level: 'degraded' }), + expect.objectContaining({ platform: 'antigravity', subject: 'mcp:oauth-docs', capability: 'auth.oauth', level: 'degraded' }), + expect.objectContaining({ platform: 'opencode', subject: 'mcp:oauth-docs', capability: 'auth.oauth', level: 'native' }), + expect.objectContaining({ platform: 'cursor', subject: 'mcp:docs', capability: 'auth.bearer', level: 'native' }), + expect.objectContaining({ platform: 'antigravity', subject: 'mcp:docs', capability: 'auth.bearer', level: 'native' }), + expect.objectContaining({ platform: 'opencode', subject: 'mcp:docs', capability: 'auth.bearer', level: 'native' }), + ])); + /** 第二轮相同输入必须产生完全相同的六平台候选字节。 */ + const firstSnapshot = await snapshotDirectory(path.join(root, 'dist')); + /** repeated 是同一工程的第二次完整事务构建。 */ + const repeated = await runProject({ cwd: root, command: 'build', mode: 'production' }); + expect(repeated.success, JSON.stringify(repeated.diagnostics)).toBe(true); + expect(await snapshotDirectory(path.join(root, 'dist'))).toEqual(firstSnapshot); + }); + + it('rejects scoped OAuth loss in strict mode without a capability waiver', async () => { + /** strict Cursor 工程稍后裁剪为一个原生 Skill 和一个 scoped OAuth Server。 */ + const root = await createCompleteProject({ strictCursor: true }); + await fs.rm(path.join(root, 'src/commands'), { recursive: true, force: true }); + await fs.rm(path.join(root, 'src/agents'), { recursive: true, force: true }); + await fs.rm(path.join(root, 'src/hooks'), { recursive: true, force: true }); + await fs.rm(path.join(root, 'src/mcp/docs'), { recursive: true, force: true }); + await fs.rm(path.join(root, 'src/mcp/local-tools'), { recursive: true, force: true }); + await fs.writeFile(path.join(root, 'src/skills/review/SKILL.md'), `--- +description: Review the current change. +--- +Review the implementation. +`); + /** scoped OAuth 是这个候选中唯一的 degraded capability。 */ + const result = await runProject({ + cwd: root, + command: 'validate', + mode: 'production', + platforms: ['cursor'], + }); + expect(result.success).toBe(false); + expect(result.compatibility).toContainEqual(expect.objectContaining({ + platform: 'cursor', subject: 'mcp:oauth-docs', capability: 'auth.oauth', level: 'degraded', + })); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ + code: 'COMPATIBILITY_STRICT_FAILURE', platform: 'cursor', + })); + }); + + it('keeps strict Pi usable for Skills and Commands but rejects Agent fallback', async () => { + /** 完整工程用于先确认 Agent fallback 的 strict 失败。 */ + const root = await createCompleteProject({ strictPi: true }); + /** Pi strictness 只对实际能力损失生效。 */ + const strictWithAgent = await runProject({ + cwd: root, + command: 'validate', + mode: 'production', + platforms: ['pi'], + }); + expect(strictWithAgent.success).toBe(false); + expect(strictWithAgent.diagnostics).toContainEqual(expect.objectContaining({ + code: 'COMPATIBILITY_STRICT_FAILURE', + platform: 'pi', + })); + + /** 移除 Agent 后保留 MCP,证明 strict 会拒绝 unsupported MCP transport。 */ + await fs.rm(path.join(root, 'src/agents'), { recursive: true, force: true }); + await fs.rm(path.join(root, 'src/hooks/permission'), { recursive: true, force: true }); + await fs.writeFile(path.join(root, 'src/skills/review/SKILL.md'), `--- +description: Review the current change. +--- +Review the implementation. +`); + /** strict MCP 失败必须指向 Pi 且报告真实 MCP compatibility。 */ + const strictWithMcp = await runProject({ + cwd: root, + command: 'validate', + mode: 'production', + platforms: ['pi'], + }); + expect(strictWithMcp.success).toBe(false); + expect(strictWithMcp.compatibility).toContainEqual(expect.objectContaining({ + platform: 'pi', subject: 'mcp:docs', capability: 'transport.http', level: 'unsupported', + })); + /** 移除 MCP 后只剩 Pi 原生/transform 能力。 */ + await fs.rm(path.join(root, 'src/mcp'), { recursive: true, force: true }); + /** strict Skills/Commands/SessionStart 构建结果。 */ + const supported = await runProject({ + cwd: root, + command: 'validate', + mode: 'production', + platforms: ['pi'], + }); + expect(supported.success).toBe(true); + }); +}); diff --git a/packages/test/test/release/ecosystem-contract.test.ts b/packages/test/test/release/ecosystem-contract.test.ts new file mode 100644 index 0000000..9c777d1 --- /dev/null +++ b/packages/test/test/release/ecosystem-contract.test.ts @@ -0,0 +1,218 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + defineExtension, + definePlatform, + type SourceFileRef, +} from '@tokenroll/acplugin/sdk'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; +import { + resolveKernelConfig, + runKernelBuildSession, +} from '@acplugin/core'; + +/** 生态契约测试创建并统一清理的临时工程。 */ +const temporaryRoots: string[] = []; + +/** 创建包含最小配置占位符且登记清理的工程。 */ +async function temporaryProject(): Promise { + /** 当前测试独占的工程根。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-ecosystem-contract-')); + temporaryRoots.push(root); + await fs.mkdir(path.join(root, 'src'), { recursive: true }); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), 'export default {}\n'); + return root; +} + +/** 第三方 Fixture 对必填 metadata 的完整处置。 */ +function metadata() { + return ['name', 'version', 'description'].map(field => ({ + field, + disposition: 'emitted' as const, + output: `plugin.json/${field}`, + reason: 'The ecosystem fixture emits this canonical field.', + })); +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('Kernel v2 ecosystem contract', () => { + it('executes SDK-created Platform, Extension, and Contributor through the fixed Package lifecycle', async () => { + /** Extension 独占来源根中的 add-only 资源。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/community'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/community/state.txt'), 'enabled\n'); + /** 第三方 Platform 只使用公开 SDK 的 Session/Document/Asset 契约。 */ + const platform = definePlatform({ + id: 'ecosystem-fixture', + apiVersion: '1', + deliveryType: 'plugin', + options: { channel: 'stable' }, + /** 每轮创建只捕获 Core 防御性复制后的选项。 */ + createSession({ options }) { + expect(options).toEqual({ channel: 'stable' }); + return { + /** Platform 只创建自己的 base Document。 */ + createPackage: ({ project }) => ({ + documents: [{ + id: 'plugin-manifest', + path: 'plugin.json', + format: 'json', + value: { + name: project.metadata.name, + version: project.metadata.version, + description: project.metadata.description, + extensions: {}, + }, + extensionPoints: [['extensions', 'community']], + }], + assets: [], + compatibility: [], + metadata: metadata(), + }), + /** Core 自动继承 base 与 Contributor 内容。 */ + finalizePackage: () => ({ id: 'plugin', type: 'plugin' }), + /** 临时候选必须已经包含 codec 输出和 Extension Asset。 */ + async validatePackage({ candidate }) { + /** manifest 是 Core Document codec 物化后的最终候选。 */ + const manifest = JSON.parse(await fs.readFile(path.join(candidate.root, 'plugin.json'), 'utf8')); + expect(manifest.extensions).toEqual({ community: { enabled: true } }); + await expect(fs.readFile(path.join(candidate.root, 'community/state.txt'), 'utf8')).resolves.toBe('enabled\n'); + }, + }; + }, + }); + /** 第三方 Extension 的状态只沿 discover→validate→build 传递。 */ + const extension = defineExtension, { readonly file: SourceFileRef }, { readonly file: SourceFileRef }, { readonly asset: import('@tokenroll/acplugin/sdk').SourceAssetRef }>({ + id: 'community-extension', + apiVersion: '1', + options: {}, + resourceRoots: ['community'], + /** 每轮返回独立且无跨 Extension 读取的 Session。 */ + createSession: () => ({ + /** discover 只能从 Extension 独占 Resource root 签发 SourceRef。 */ + async discover({ roots, sources }) { + /** 缺失 root 时按未发现处理,不生成兼容性噪声。 */ + const sourceRoot = roots.community; + return sourceRoot === undefined ? undefined : { file: await sources.file(sourceRoot, 'state.txt') }; + }, + /** validate 声明 Contributor 后续必须覆盖的 capability tuple。 */ + validate: (_context, discovered) => ({ + state: discovered, + subjects: [{ subject: 'community:state', capabilities: ['delivery'] }], + }), + /** build 只通过 owner-scoped Asset Service 转换 SourceRef。 */ + build: async ({ assets }, validated) => ({ + state: { asset: await assets.fromSource(validated.file) }, + }), + contributors: [{ + platform: 'ecosystem-fixture', + platformApiVersion: '1', + /** Contributor 只填声明点并追加自己拥有的 Asset。 */ + contribute: (_context, built) => ({ + documentFields: [{ + document: 'plugin-manifest', + path: ['extensions', 'community'], + value: { enabled: true }, + }], + assets: [{ path: 'community/state.txt', asset: built.asset }], + compatibility: [{ + subject: 'community:state', + capability: 'delivery', + level: 'native', + reason: 'The extension contributes through a declared Package extension point.', + }], + }), + }], + }), + }); + /** 私有测试直接调用 Kernel,生产配置仍只通过公开品牌对象。 */ + const resolved = resolveKernelConfig({ + name: 'ecosystem-test', + version: '1.0.0', + description: 'Ecosystem contract.', + public: false, + platforms: [platform], + extensions: [extension], + }, { + projectRoot: root, + configFile: path.join(root, 'acplugin.config.ts'), + command: 'inspect', + mode: 'production', + }); + /** BuildSession 是测试中唯一实际执行的构建路径。 */ + const result = await runKernelBuildSession({ + config: resolved.config!, + frameworkVersion: 'test', + commit: false, + }); + + expect(resolved.diagnostics).toEqual([]); + expect(result.report.success, JSON.stringify(result.report.diagnostics, null, 2)).toBe(true); + expect(result.report.packages).toContainEqual(expect.objectContaining({ + platform: 'ecosystem-fixture', + id: 'plugin', + validated: true, + assets: expect.arrayContaining([ + expect.objectContaining({ path: 'plugin.json', owner: 'platform:ecosystem-fixture' }), + expect.objectContaining({ path: 'community/state.txt', owner: 'extension:community-extension' }), + ]), + })); + expect(result.report.compatibility).toContainEqual(expect.objectContaining({ + platform: 'ecosystem-fixture', + subject: 'community:state', + capability: 'delivery', + level: 'native', + })); + }); + + it('delivers one Core-built Node Runtime byte stream to Claude Code and Codex', async () => { + /** 工程包含一个原生 Skill 和一个自动发现的 Runtime 入口。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/skills/host'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/runtime'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/skills/host/SKILL.md'), '---\ndescription: Host the runtime.\n---\nUse the runtime.\n'); + await fs.writeFile(path.join(root, 'src/runtime/cli.ts'), 'process.stdout.write("runtime-ready\\n");\n'); + /** 两个官方 Platform 都只声明能力,不各自编译 Runtime。 */ + const resolved = resolveKernelConfig({ + name: 'runtime-ecosystem', + version: '1.0.0', + description: 'Cross-platform runtime contract.', + public: false, + platforms: [claudeCode(), codex()], + }, { + projectRoot: root, + configFile: path.join(root, 'acplugin.config.ts'), + command: 'build', + mode: 'production', + }); + /** commit 验证最终两个 Platform 目录中的真实字节。 */ + const result = await runKernelBuildSession({ + config: resolved.config!, + frameworkVersion: 'test', + commit: true, + }); + /** 两个 Package 报告中的 Runtime 必须继承同一个 Core provenance/hash/mode。 */ + const runtimes = result.report.packages + .filter(unit => unit.role === 'primary') + .map(unit => unit.assets.find(asset => asset.path === 'runtime/cli/main.mjs')!); + + expect(resolved.diagnostics).toEqual([]); + expect(result.report.success, JSON.stringify(result.report.diagnostics, null, 2)).toBe(true); + expect(runtimes).toHaveLength(2); + expect(runtimes[0]).toMatchObject({ + owner: 'framework:node-runtime', + mode: 0o755, + origin: { type: 'compile', profile: 'portable-node' }, + }); + expect(runtimes[1]).toEqual(runtimes[0]); + await expect(fs.readFile(path.join(root, 'dist/claude-code/plugin/runtime/cli/main.mjs'))).resolves.toEqual( + await fs.readFile(path.join(root, 'dist/codex/plugin/runtime/cli/main.mjs')), + ); + }); +}); diff --git a/packages/test/test/release/package-boundaries.test.ts b/packages/test/test/release/package-boundaries.test.ts new file mode 100644 index 0000000..822fd85 --- /dev/null +++ b/packages/test/test/release/package-boundaries.test.ts @@ -0,0 +1,171 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +/** 包边界测试读取构建产物和清单时使用的仓库根目录。 */ +const root = fileURLToPath(new URL('../../../..', import.meta.url)); + +/** 六个独立 Platform package 目录及其公开工厂名。 */ +const platformEntries = [ + ['claude-code', 'claudeCode'], + ['codex', 'codex'], + ['cursor', 'cursor'], + ['antigravity', 'antigravity'], + ['opencode', 'openCode'], + ['pi', 'pi'], +] as const; + +/** 九个正式公开包的清单路径。 */ +const publicManifests = [ + 'packages/acplugin/package.json', + ...platformEntries.map(([id]) => `packages/platforms/${id}/package.json`), + 'packages/extensions/hooks/package.json', + 'packages/extensions/mcp/package.json', +] as const; + +/** 递归检查时不属于源码或 Workspace 拓扑的依赖与可重建输出目录。 */ +const ignoredGeneratedDirectories = new Set(['.vitepress', 'api', 'dist', 'node_modules']); + +/** + * 递归读取目录中满足后缀要求的全部文件。 + * + * @param directory 待遍历目录。 + * @param suffixes 需要保留的文件后缀。 + * @returns 按路径排序的绝对文件列表。 + */ +async function filesWithSuffixes(directory: string, suffixes: readonly string[]): Promise { + /** 当前层按文件名排序后的目录项。 */ + const entries = await fs.readdir(directory, { withFileTypes: true }); + /** 当前目录和所有子目录累计的匹配文件。 */ + const files: string[] = []; + /** entry 表示当前排序后的目录项,用于递归收集目标后缀。 */ + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name, 'en'))) { + if (entry.isDirectory() && ignoredGeneratedDirectories.has(entry.name)) + continue; + /** 当前目录项的绝对路径。 */ + const candidate = path.join(directory, entry.name); + if (entry.isDirectory()) + files.push(...await filesWithSuffixes(candidate, suffixes)); + else if (suffixes.some(suffix => entry.name.endsWith(suffix))) + files.push(candidate); + } + return files; +} + +/** + * 读取并拼接一组文本构建产物,便于执行跨 Chunk 边界扫描。 + * + * @param files 需要读取的绝对文件路径。 + * @returns 包含相对路径标记的完整文本。 + */ +async function joinedSources(files: readonly string[]): Promise { + /** 各文件路径和源码组成的确定性片段。 */ + const sources = await Promise.all(files.map(async (file) => { + /** 当前产物的 UTF-8 源码。 */ + const source = await fs.readFile(file, 'utf8'); + return `\n${path.relative(root, file)}\n${source}`; + })); + return sources.join(''); +} + +describe('published package boundaries', () => { + it('bundles every private workspace runtime out of the main package', async () => { + /** 主包所有 ESM 和声明构建产物。 */ + const files = await filesWithSuffixes(path.join(root, 'packages/acplugin/dist'), ['.mjs', '.d.mts']); + /** 用于检测私有工作区引用泄漏的完整产物文本。 */ + const source = await joinedSources(files); + /** 仅运行时产物用于检查 Rolldown 是否被静态链接;声明文件可以合法导入其公开类型。 */ + const runtimeFiles = await filesWithSuffixes(path.join(root, 'packages/acplugin/dist'), ['.mjs']); + /** 主包所有 ESM 运行时产物。 */ + const runtimeSource = await joinedSources(runtimeFiles); + + expect(source).not.toMatch(/from\s+["']@acplugin\//); + expect(source).not.toMatch(/import\s*\(\s*["']@acplugin\//); + expect(source).not.toMatch(/^\s*(?:import|export)\s.*from\s+["']@tokenroll\/acplugin-(?:platform-(?:claude-code|codex|cursor|antigravity|opencode|pi)|extension-(?:hooks|mcp|node-runtime))["']/m); + expect(source).not.toMatch(/^\s*import\s*\(\s*["']@tokenroll\/acplugin-(?:platform-(?:claude-code|codex|cursor|antigravity|opencode|pi)|extension-(?:hooks|mcp|node-runtime))["']/m); + expect(source).not.toMatch(/type\s+(?:AcpluginModule|TargetContribution|TargetId)\b/); + expect(source).not.toMatch(/type\s+Module(?:Build|Discover|Generate|Validate)Context\b/); + expect(runtimeSource).not.toMatch(/^\s*import\s.*from\s+["'](?:rolldown|rolldown\/parseAst)["']/m); + expect(runtimeSource).toContain('import("rolldown")'); + }); + + it('uses the Core Build Service from the single published MCP entry', async () => { + /** MCP 发布入口包含协议 smoke 与 Core Build Service 调用,但不包含私有 Rolldown driver。 */ + const index = await fs.readFile(path.join(root, 'packages/extensions/mcp/dist/index.mjs'), 'utf8'); + expect(index).toContain('context.compiler.compile'); + expect(index).not.toMatch(/^\s*import\s.*from\s+["'](?:rolldown|@rolldown\/)/m); + await expect(fs.access(path.join(root, 'packages/extensions/mcp/dist/bundler.mjs'))).rejects.toThrow(); + }); + + it('keeps each independent Platform package limited to its public factory contract', async () => { + /** id 与 factory 表示当前检查的 Platform package 及其具名工厂导出。 */ + for (const [id, factory] of platformEntries) { + /** 从独立 package 真实构建文件加载的运行时命名空间。 */ + const module = await import(pathToFileURL(path.join(root, `packages/platforms/${id}/dist/index.mjs`)).href); + expect(Object.keys(module).sort()).toEqual(['PLATFORM_API_VERSION', 'PLATFORM_ID', 'default', factory].sort()); + expect(module.default).toBe(module[factory]); + /** 当前独立 package 生成的声明入口。 */ + const declaration = await fs.readFile(path.join(root, `packages/platforms/${id}/dist/index.d.mts`), 'utf8'); + expect(declaration).toContain('from "@tokenroll/acplugin/sdk"'); + expect(declaration).not.toContain('@acplugin/'); + expect(declaration).not.toMatch(/\b(?:Compiler|Serializer|Validator|Registry|executeLifecycle|buildProject)\b/); + } + }); + + it('externalizes the public main package from all Platform and Extension packages', async () => { + /** integration 表示当前检查的正式生态包目录。 */ + const integrations = [ + ...platformEntries.map(([id]) => `platforms/${id}`), + 'extensions/hooks', + 'extensions/mcp', + ]; + for (const integration of integrations) { + /** 当前生态包的 ESM 与声明入口源码。 */ + const files = [ + path.join(root, `packages/${integration}/dist/index.mjs`), + path.join(root, `packages/${integration}/dist/index.d.mts`), + ]; + /** 两个入口共同构成的包边界文本。 */ + const source = await joinedSources(files); + expect(source).toContain('from "@tokenroll/acplugin/sdk"'); + expect(source).not.toContain('@acplugin/'); + } + }); + + it('publishes only the nine independent packages and a Node 20 ESM CLI', async () => { + /** Workspace 中所有 package.json 路径。 */ + const manifests = await filesWithSuffixes(path.join(root, 'packages'), ['package.json']); + /** 未声明 private 的实际公开包名称。 */ + const publicNames: string[] = []; + /** manifestPath 表示当前解析公开性字段的 Workspace 清单。 */ + for (const manifestPath of manifests) { + /** 当前 Workspace 包清单。 */ + const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as { name: string; private?: boolean }; + if (manifest.private !== true) + publicNames.push(manifest.name); + } + expect(publicNames.sort()).toEqual([ + '@tokenroll/acplugin', + ...platformEntries.map(([id]) => `@tokenroll/acplugin-platform-${id}`), + '@tokenroll/acplugin-extension-hooks', + '@tokenroll/acplugin-extension-mcp', + ].sort()); + expect(publicManifests).toHaveLength(9); + + /** 主包不得再声明或生成官方 Platform subpath。 */ + const mainManifest = JSON.parse(await fs.readFile(path.join(root, 'packages/acplugin/package.json'), 'utf8')) as { exports: Record }; + expect(Object.keys(mainManifest.exports)).toEqual(['.', './sdk']); + await expect(fs.access(path.join(root, 'packages/acplugin/dist/platforms'))).rejects.toThrow(); + + /** 主包生成并由 package.json bin 指向的 CLI 文件。 */ + const cliPath = path.join(root, 'packages/acplugin/dist/cli.mjs'); + /** CLI shebang 与 ESM 源码。 */ + const cli = await fs.readFile(cliPath, 'utf8'); + /** CLI 文件系统权限。 */ + const stat = await fs.stat(cliPath); + expect(cli.startsWith('#!/usr/bin/env node\n')).toBe(true); + expect(stat.mode & 0o111).not.toBe(0); + expect(cli).not.toMatch(/\brequire\s*\(/); + }); +}); diff --git a/packages/test/tsconfig.json b/packages/test/tsconfig.json new file mode 100644 index 0000000..e523885 --- /dev/null +++ b/packages/test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "exactOptionalPropertyTypes": false, + "noUncheckedIndexedAccess": false + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/test/tsconfig.sdk.json b/packages/test/tsconfig.sdk.json new file mode 100644 index 0000000..8dae822 --- /dev/null +++ b/packages/test/tsconfig.sdk.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["test/sdk-api.types.ts"] +} diff --git a/packages/test/vitest.config.ts b/packages/test/vitest.config.ts new file mode 100644 index 0000000..ece91a9 --- /dev/null +++ b/packages/test/vitest.config.ts @@ -0,0 +1,36 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +/** + * 把测试工作区相对路径解析为可供 Vitest Alias 使用的绝对源码入口。 + * + * @param path 相对于 packages/test 的入口路径。 + * @returns 绝对文件系统路径。 + */ +function workspaceSource(path: string): string { + return fileURLToPath(new URL(path, import.meta.url)); +} + +// 集成测试直接 Alias 到工作区源码;pretest 仍会按依赖顺序构建全部正式包,以覆盖真实产物路径。 +export default defineConfig({ + test: { + environment: 'node', + }, + resolve: { + alias: [ + { find: '@tokenroll/acplugin/sdk', replacement: workspaceSource('../acplugin/src/sdk.ts') }, + { find: '@acplugin/core/integration', replacement: workspaceSource('../core/src/api/integration.ts') }, + { find: '@acplugin/core/author', replacement: workspaceSource('../core/src/api/author.ts') }, + { find: '@acplugin/core', replacement: workspaceSource('../core/src/index.ts') }, + { find: '@tokenroll/acplugin', replacement: workspaceSource('../acplugin/src/index.ts') }, + { find: '@tokenroll/acplugin-platform-antigravity', replacement: workspaceSource('../platforms/antigravity/src/index.ts') }, + { find: '@tokenroll/acplugin-platform-claude-code', replacement: workspaceSource('../platforms/claude-code/src/index.ts') }, + { find: '@tokenroll/acplugin-platform-codex', replacement: workspaceSource('../platforms/codex/src/index.ts') }, + { find: '@tokenroll/acplugin-platform-cursor', replacement: workspaceSource('../platforms/cursor/src/index.ts') }, + { find: '@tokenroll/acplugin-platform-opencode', replacement: workspaceSource('../platforms/opencode/src/index.ts') }, + { find: '@tokenroll/acplugin-platform-pi', replacement: workspaceSource('../platforms/pi/src/index.ts') }, + { find: '@tokenroll/acplugin-extension-hooks', replacement: workspaceSource('../extensions/hooks/src/index.ts') }, + { find: '@tokenroll/acplugin-extension-mcp', replacement: workspaceSource('../extensions/mcp/src/index.ts') }, + ], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..48b07a4 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,6355 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +catalogs: + default: + '@types/node': + specifier: ^20.19.0 + version: 20.19.43 + '@typescript/native': + specifier: npm:typescript@^7.0.2 + version: 7.0.2 + rolldown: + specifier: 1.2.2 + version: 1.2.2 + tsdown: + specifier: ^0.22.14 + version: 0.22.14 + vitest: + specifier: ^4.1.10 + version: 4.1.10 + +importers: + + .: + devDependencies: + '@arethetypeswrong/core': + specifier: ^0.18.5 + version: 0.18.5 + '@changesets/cli': + specifier: ^2.31.1 + version: 2.31.1(@types/node@20.19.43) + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.8.0(jiti@2.7.0)) + '@stylistic/eslint-plugin': + specifier: ^5.10.0 + version: 5.10.0(eslint@10.8.0(jiti@2.7.0)) + '@tokenroll/acplugin-extension-mcp': + specifier: workspace:^ + version: link:packages/extensions/mcp + '@tokenroll/acplugin-platform-claude-code': + specifier: workspace:^ + version: link:packages/platforms/claude-code + '@types/node': + specifier: 'catalog:' + version: 20.19.43 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 + eslint: + specifier: ^10.8.0 + version: 10.8.0(jiti@2.7.0) + husky: + specifier: ^9.1.7 + version: 9.1.7 + lint-staged: + specifier: ^17.2.0 + version: 17.2.0 + publint: + specifier: ^0.3.23 + version: 0.3.23 + tsdown: + specifier: 'catalog:' + version: 0.22.14(@arethetypeswrong/core@0.18.5)(@typescript/typescript6@6.0.2)(publint@0.3.23) + typescript: + specifier: npm:@typescript/typescript6@^6.0.2 + version: '@typescript/typescript6@6.0.2' + typescript-eslint: + specifier: ^8.66.0 + version: 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)) + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) + + packages/acplugin: + dependencies: + '@inquirer/prompts': + specifier: ^8.3.2 + version: 8.5.2(@types/node@20.19.43) + commander: + specifier: 14.0.1 + version: 14.0.1 + gray-matter: + specifier: ^4.0.3 + version: 4.0.3 + rolldown: + specifier: 'catalog:' + version: 1.2.2 + semver: + specifier: ^7.8.5 + version: 7.8.5 + spdx-expression-parse: + specifier: ^5.0.0 + version: 5.0.0 + devDependencies: + '@acplugin/core': + specifier: workspace:* + version: link:../core + '@types/node': + specifier: 'catalog:' + version: 20.19.43 + '@types/semver': + specifier: ^7.7.1 + version: 7.8.0 + '@types/spdx-expression-parse': + specifier: ^4.0.0 + version: 4.0.0 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 + tsdown: + specifier: 'catalog:' + version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@7.0.2) + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) + + packages/core: + dependencies: + chokidar: + specifier: ^5.0.0 + version: 5.0.0 + rolldown: + specifier: 'catalog:' + version: 1.2.2 + semver: + specifier: ^7.8.5 + version: 7.8.5 + smol-toml: + specifier: ^1.8.0 + version: 1.8.0 + spdx-expression-parse: + specifier: ^5.0.0 + version: 5.0.0 + yaml: + specifier: ^2.9.0 + version: 2.9.0 + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 20.19.43 + '@types/semver': + specifier: ^7.7.1 + version: 7.8.0 + '@types/spdx-expression-parse': + specifier: ^4.0.0 + version: 4.0.0 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 + tsdown: + specifier: 'catalog:' + version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@7.0.2) + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) + + packages/docs: + devDependencies: + typedoc: + specifier: ^0.28.20 + version: 0.28.20(@typescript/typescript6@6.0.2) + typedoc-plugin-markdown: + specifier: ^4.12.0 + version: 4.12.0(typedoc@0.28.20(@typescript/typescript6@6.0.2)) + typedoc-vitepress-theme: + specifier: ^1.1.3 + version: 1.1.3(typedoc-plugin-markdown@4.12.0(typedoc@0.28.20(@typescript/typescript6@6.0.2))) + typescript: + specifier: npm:@typescript/typescript6@^6.0.2 + version: '@typescript/typescript6@6.0.2' + vitepress: + specifier: ^1.6.4 + version: 1.6.4(@algolia/client-search@5.56.0)(@types/node@20.19.43)(@typescript/typescript6@6.0.2)(postcss@8.5.25)(search-insights@2.17.3) + + packages/extensions/hooks: + devDependencies: + '@tokenroll/acplugin': + specifier: workspace:^ + version: link:../../acplugin + '@types/node': + specifier: 'catalog:' + version: 20.19.43 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 + tsdown: + specifier: 'catalog:' + version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@7.0.2) + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) + + packages/extensions/mcp: + devDependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.30.0 + version: 1.30.0(zod@4.4.3) + '@tokenroll/acplugin': + specifier: workspace:^ + version: link:../../acplugin + '@types/node': + specifier: 'catalog:' + version: 20.19.43 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 + tsdown: + specifier: 'catalog:' + version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@7.0.2) + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) + + packages/platforms/antigravity: + devDependencies: + '@acplugin/core': + specifier: workspace:* + version: link:../../core + '@tokenroll/acplugin': + specifier: workspace:^ + version: link:../../acplugin + '@types/node': + specifier: 'catalog:' + version: 20.19.43 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 + tsdown: + specifier: 'catalog:' + version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@7.0.2) + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) + + packages/platforms/claude-code: + devDependencies: + '@acplugin/core': + specifier: workspace:* + version: link:../../core + '@tokenroll/acplugin': + specifier: workspace:^ + version: link:../../acplugin + '@types/node': + specifier: 'catalog:' + version: 20.19.43 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 + tsdown: + specifier: 'catalog:' + version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@7.0.2) + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) + + packages/platforms/codex: + dependencies: + image-size: + specifier: ^2.0.2 + version: 2.0.2 + saxes: + specifier: ^6.0.0 + version: 6.0.0 + yaml: + specifier: ^2.9.0 + version: 2.9.0 + devDependencies: + '@acplugin/core': + specifier: workspace:* + version: link:../../core + '@tokenroll/acplugin': + specifier: workspace:^ + version: link:../../acplugin + '@types/node': + specifier: 'catalog:' + version: 20.19.43 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 + tsdown: + specifier: 'catalog:' + version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@7.0.2) + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) + + packages/platforms/cursor: + devDependencies: + '@acplugin/core': + specifier: workspace:* + version: link:../../core + '@tokenroll/acplugin': + specifier: workspace:^ + version: link:../../acplugin + '@types/node': + specifier: 'catalog:' + version: 20.19.43 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 + tsdown: + specifier: 'catalog:' + version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@7.0.2) + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) + + packages/platforms/opencode: + devDependencies: + '@acplugin/core': + specifier: workspace:* + version: link:../../core + '@tokenroll/acplugin': + specifier: workspace:^ + version: link:../../acplugin + '@types/node': + specifier: 'catalog:' + version: 20.19.43 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 + tsdown: + specifier: 'catalog:' + version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@7.0.2) + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) + + packages/platforms/pi: + devDependencies: + '@acplugin/core': + specifier: workspace:* + version: link:../../core + '@tokenroll/acplugin': + specifier: workspace:^ + version: link:../../acplugin + '@types/node': + specifier: 'catalog:' + version: 20.19.43 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 + tsdown: + specifier: 'catalog:' + version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@7.0.2) + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) + + packages/playground: + devDependencies: + '@tokenroll/acplugin': + specifier: workspace:^ + version: link:../acplugin + '@tokenroll/acplugin-extension-hooks': + specifier: workspace:^ + version: link:../extensions/hooks + '@tokenroll/acplugin-extension-mcp': + specifier: workspace:^ + version: link:../extensions/mcp + '@tokenroll/acplugin-platform-antigravity': + specifier: workspace:^ + version: link:../platforms/antigravity + '@tokenroll/acplugin-platform-claude-code': + specifier: workspace:^ + version: link:../platforms/claude-code + '@tokenroll/acplugin-platform-codex': + specifier: workspace:^ + version: link:../platforms/codex + '@tokenroll/acplugin-platform-cursor': + specifier: workspace:^ + version: link:../platforms/cursor + '@tokenroll/acplugin-platform-opencode': + specifier: workspace:^ + version: link:../platforms/opencode + '@tokenroll/acplugin-platform-pi': + specifier: workspace:^ + version: link:../platforms/pi + '@types/node': + specifier: 'catalog:' + version: 20.19.43 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 + + packages/test: + dependencies: + '@acplugin/core': + specifier: workspace:* + version: link:../core + '@tokenroll/acplugin': + specifier: workspace:* + version: link:../acplugin + '@tokenroll/acplugin-extension-hooks': + specifier: workspace:* + version: link:../extensions/hooks + '@tokenroll/acplugin-extension-mcp': + specifier: workspace:* + version: link:../extensions/mcp + '@tokenroll/acplugin-platform-antigravity': + specifier: workspace:* + version: link:../platforms/antigravity + '@tokenroll/acplugin-platform-claude-code': + specifier: workspace:* + version: link:../platforms/claude-code + '@tokenroll/acplugin-platform-codex': + specifier: workspace:* + version: link:../platforms/codex + '@tokenroll/acplugin-platform-cursor': + specifier: workspace:* + version: link:../platforms/cursor + '@tokenroll/acplugin-platform-opencode': + specifier: workspace:* + version: link:../platforms/opencode + '@tokenroll/acplugin-platform-pi': + specifier: workspace:* + version: link:../platforms/pi + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 20.19.43 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) + +packages: + + '@algolia/abtesting@1.22.0': + resolution: {integrity: sha512-BFR6zNowNKcY7Ou7TaJc9QWexES4YKPbmf/OTFofpdsdhz4x6q0lbxp3duO0EHnyrN7rE4ba/TSXuY+BDGu4+g==} + engines: {node: '>= 14.0.0'} + + '@algolia/autocomplete-core@1.17.7': + resolution: {integrity: sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==} + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7': + resolution: {integrity: sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==} + peerDependencies: + search-insights: '>= 1 < 3' + + '@algolia/autocomplete-preset-algolia@1.17.7': + resolution: {integrity: sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/autocomplete-shared@1.17.7': + resolution: {integrity: sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/client-abtesting@5.56.0': + resolution: {integrity: sha512-7r4Z3NC7yU1oAQVWJNA2HX7tX481F3pJvCGyLIXiTdBcthz4Q/o21jwcMYDFkuI92UWTNBQQmHYgwHo1zS5dzg==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-analytics@5.56.0': + resolution: {integrity: sha512-avmjXQSq+jadFO8Xl2em05/uQdQnEmHsJyOAdVbZkmVgpMfxL12aJwVVfGNwYr9nulcpuJN1X0lTaQ5wxuNGcA==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-common@5.56.0': + resolution: {integrity: sha512-v2TPStUhY//ripPjIVclZ8AWc7DEGooXULZGFlFu37zNatgHjw34oZZ+OSbbc/YHO+xZwPl62I1k8xH1m4S2eg==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-insights@5.56.0': + resolution: {integrity: sha512-P0ehROpM4Sem3Sqo5x2cKPgj67D3G3jy0rh1Amwkcvsfr6tkvIcdCmerieanqTF7NxUMPNFLkpIFeMO8Rpa50w==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-personalization@5.56.0': + resolution: {integrity: sha512-SXK3Vn3WVxyzbm31oePZBJkp1wpOyuWdd4B/Pv7n0aXDxmeSWhC1R1FC1517mMrFAIaPH4Rt0x6RUe7ZNjz8FA==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-query-suggestions@5.56.0': + resolution: {integrity: sha512-5+ZdX8garFnmycnZgKhtXHePEaLj5zqDxI/0lkhhluzCcvTn0/PvvTirTg8hHYetQHvn7GDyeAiqTAieMvMW4A==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-search@5.56.0': + resolution: {integrity: sha512-+mKUdYvqOi0BcvpAEyCEw49vSBptufIcfibtHz2bdr1pI789M46Yt0uQEk/sxtK3teh71OQvVFHaTDzShUWewQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/ingestion@1.56.0': + resolution: {integrity: sha512-9g/zj+AZx5moFcdFIrYQoVrueXivjUcc3MQHtCYT8WhIuk1lUh1AyEhvJCS0XBZld09cLvd1AZ3BvDBpVpX2UA==} + engines: {node: '>= 14.0.0'} + + '@algolia/monitoring@1.56.0': + resolution: {integrity: sha512-Qf3Sr6f9A9uxCZUf3MXS0d2b877uYzEB5yxqpVGXAhcJnBCQjrRRon0KvefpGkxy+BshrIJs96OUoMtGqXTFDA==} + engines: {node: '>= 14.0.0'} + + '@algolia/recommend@5.56.0': + resolution: {integrity: sha512-GXWG1rWc5wu8hY4N33Y3b6ernY6sAdAvmKWN/zHAiACOx40WnpG0TVX5YazCAr/9gOYGInSiM2A0y2jy2xbiDA==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-browser-xhr@5.56.0': + resolution: {integrity: sha512-7t24cBxaInS3mZb7ddEaZT/tp6q+/aR4YttsQVyP1/i+LmwPR34atO35KjaLFCcRVrlP7sYOAqkCfg6lIRB+ew==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-fetch@5.56.0': + resolution: {integrity: sha512-R7ePHgVYmDFjZpvrsVAfbDz/d4RxKAYZ5/vgLfIsCVRZRryjWl/3INOxpOICzitehQ5FjNtNjcLQTrmHPTcHBQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-node-http@5.56.0': + resolution: {integrity: sha512-PIOUXlSnrqM0S+WOgDRb4RzotydJH7ZoT6tOyL7tAO7qJOfvX5wsEW8Pe+PMKMwvuI4/gIyK9cg2H7lJXqnc4Q==} + engines: {node: '>= 14.0.0'} + + '@andrewbranch/untar.js@1.0.3': + resolution: {integrity: sha512-Jh15/qVmrLGhkKJBdXlK1+9tY4lZruYjsgkDFj08ZmDiWVBLJcqkok7Z0/R0In+i1rScBpJlSvrTS2Lm41Pbnw==} + + '@arethetypeswrong/core@0.18.5': + resolution: {integrity: sha512-9ytjzGwxjm9Uz7I9avfbt5vlQt6uk9uRRESzJjqrznl6WKvI6dwYTo+vJ3U02Wrq/mR3iql/PzhvHhKdJIAjDQ==} + engines: {node: '>=20'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@braidai/lang@1.1.2': + resolution: {integrity: sha512-qBcknbBufNHlui137Hft8xauQMTZDKdophmLFv05r2eNmdIv/MlPuP4TdUknHG68UdWLgVZwgxVe735HzJNIwA==} + + '@changesets/apply-release-plan@7.1.1': + resolution: {integrity: sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==} + + '@changesets/assemble-release-plan@6.0.10': + resolution: {integrity: sha512-rSDcqdJ9KbVyjpBIuCidhvZNIiVt1XaIYp73ycVQRIA5n/j6wQaEk0ChRLMUQ1vkxZe51PTQ9OIhbg6HQMW45A==} + + '@changesets/changelog-git@0.2.1': + resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==} + + '@changesets/cli@2.31.1': + resolution: {integrity: sha512-uO05WTcRBwuVOJVSW8Cmpqw6q0WDL53ajGCMyszutvOe5toOnunbpM4jZzf+qxBOz7i0AzopZ8diBuewjmF40w==} + hasBin: true + + '@changesets/config@3.1.4': + resolution: {integrity: sha512-pf0bvD/v6WI2cRlZ6hzpjtZdSlXDXMAJ+Iz7xfFzV4ZxJ8OGGAON+1qYc99ZPrijnt4xp3VGG7eNvAOGS24V1Q==} + + '@changesets/errors@0.2.0': + resolution: {integrity: sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==} + + '@changesets/get-dependents-graph@2.1.4': + resolution: {integrity: sha512-ZsS00x6WvmHq3sQv8oCMwL0f/z3wbXCVuSVTJwCnnmbC/iBdNJGFx1EcbMG4PC6sXRyH69liM4A2WKXzn/kRPg==} + + '@changesets/get-release-plan@4.0.16': + resolution: {integrity: sha512-2K5Om6CrMPm45rtvckfzWo7e9jOVCKLCnXia5eUPaURH7/LWzri7pK1TycdzAuAtehLkW7VPbWLCSExTHmiI6g==} + + '@changesets/get-version-range-type@0.4.0': + resolution: {integrity: sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==} + + '@changesets/git@3.0.4': + resolution: {integrity: sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw==} + + '@changesets/logger@0.1.1': + resolution: {integrity: sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==} + + '@changesets/parse@0.4.3': + resolution: {integrity: sha512-ZDmNc53+dXdWEv7fqIUSgRQOLYoUom5Z40gmLgmATmYR9NbL6FJJHwakcCpzaeCy+1D0m0n7mT4jj2B/MQPl7A==} + + '@changesets/pre@2.0.2': + resolution: {integrity: sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug==} + + '@changesets/read@0.6.7': + resolution: {integrity: sha512-D1G4AUYGrBEk8vj8MGwf75k9GpN6XL3wg8i42P2jZZwFLXnlr2Pn7r9yuQNbaMCarP7ZQWNJbV6XLeysAIMhTA==} + + '@changesets/should-skip-package@0.1.2': + resolution: {integrity: sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==} + + '@changesets/types@4.1.0': + resolution: {integrity: sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==} + + '@changesets/types@6.1.0': + resolution: {integrity: sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==} + + '@changesets/write@0.4.0': + resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + + '@docsearch/css@3.8.2': + resolution: {integrity: sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==} + + '@docsearch/js@3.8.2': + resolution: {integrity: sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==} + + '@docsearch/react@3.8.2': + resolution: {integrity: sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==} + peerDependencies: + '@types/react': '>= 16.8.0 < 19.0.0' + react: '>= 16.8.0 < 19.0.0' + react-dom: '>= 16.8.0 < 19.0.0' + search-insights: '>= 1 < 3' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + react-dom: + optional: true + search-insights: + optional: true + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.7.0': + resolution: {integrity: sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@gerrit0/mini-shiki@3.23.0': + resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==} + + '@hono/node-server@2.1.1': + resolution: {integrity: sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==} + engines: {node: '>=20'} + peerDependencies: + hono: ^4 + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@iconify-json/simple-icons@1.2.93': + resolution: {integrity: sha512-/XhANjfGYOuqvSR3TmUnkQkINvQ4GVjVuukvymRbxtVFBvIq/yiXJqCDycKcQPT401OYT9H2vIY6ihAlz1QIAw==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@inquirer/ansi@2.0.7': + resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/checkbox@5.2.1': + resolution: {integrity: sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@6.1.1': + resolution: {integrity: sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@11.2.1': + resolution: {integrity: sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@5.2.2': + resolution: {integrity: sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@5.1.1': + resolution: {integrity: sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@3.0.3': + resolution: {integrity: sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@2.0.7': + resolution: {integrity: sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/input@5.1.2': + resolution: {integrity: sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@4.1.1': + resolution: {integrity: sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@5.1.1': + resolution: {integrity: sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@8.5.2': + resolution: {integrity: sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@5.3.1': + resolution: {integrity: sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@4.2.1': + resolution: {integrity: sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@5.2.1': + resolution: {integrity: sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@4.0.7': + resolution: {integrity: sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@loaderkit/resolve@1.0.6': + resolution: {integrity: sha512-G8FdIoF5CypfwmD9rl8BXod5HDn8JqB0CCNBXDTaRZ+yRYhARrrSToX1zg1zy9jX3zLqigsELwhT4gNtkdQAUg==} + + '@manypkg/find-root@1.1.0': + resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} + + '@manypkg/get-packages@1.1.3': + resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + + '@modelcontextprotocol/sdk@1.30.0': + resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@oxc-project/types@0.142.0': + resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} + + '@publint/pack@0.1.6': + resolution: {integrity: sha512-3uVNyGcVplhPZSLVyeIpL7+cIRn1YCSNHLG/rUIlBQMVH8YuN9++YF+5+UDIIO9RW98dujiUoTltO7RDB5bFJA==} + engines: {node: '>=18'} + + '@quansync/fs@1.0.0': + resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} + + '@rolldown/binding-android-arm64@1.2.2': + resolution: {integrity: sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.2': + resolution: {integrity: sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.2': + resolution: {integrity: sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.2': + resolution: {integrity: sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.2': + resolution: {integrity: sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.2': + resolution: {integrity: sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.2': + resolution: {integrity: sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.2': + resolution: {integrity: sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.2': + resolution: {integrity: sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.2': + resolution: {integrity: sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.2': + resolution: {integrity: sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.2': + resolution: {integrity: sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.2': + resolution: {integrity: sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.2': + resolution: {integrity: sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@rollup/rollup-android-arm-eabi@4.62.3': + resolution: {integrity: sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.3': + resolution: {integrity: sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.3': + resolution: {integrity: sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.3': + resolution: {integrity: sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.3': + resolution: {integrity: sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.3': + resolution: {integrity: sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.3': + resolution: {integrity: sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.3': + resolution: {integrity: sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.3': + resolution: {integrity: sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.3': + resolution: {integrity: sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.3': + resolution: {integrity: sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.3': + resolution: {integrity: sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.3': + resolution: {integrity: sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.3': + resolution: {integrity: sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.3': + resolution: {integrity: sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.3': + resolution: {integrity: sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.3': + resolution: {integrity: sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.3': + resolution: {integrity: sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.3': + resolution: {integrity: sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.3': + resolution: {integrity: sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.3': + resolution: {integrity: sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.3': + resolution: {integrity: sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.3': + resolution: {integrity: sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.3': + resolution: {integrity: sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.3': + resolution: {integrity: sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==} + cpu: [x64] + os: [win32] + + '@shikijs/core@2.5.0': + resolution: {integrity: sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==} + + '@shikijs/engine-javascript@2.5.0': + resolution: {integrity: sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==} + + '@shikijs/engine-oniguruma@2.5.0': + resolution: {integrity: sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==} + + '@shikijs/engine-oniguruma@3.23.0': + resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} + + '@shikijs/langs@2.5.0': + resolution: {integrity: sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==} + + '@shikijs/langs@3.23.0': + resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==} + + '@shikijs/themes@2.5.0': + resolution: {integrity: sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==} + + '@shikijs/themes@3.23.0': + resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==} + + '@shikijs/transformers@2.5.0': + resolution: {integrity: sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==} + + '@shikijs/types@2.5.0': + resolution: {integrity: sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==} + + '@shikijs/types@3.23.0': + resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@stylistic/eslint-plugin@5.10.0': + resolution: {integrity: sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^9.0.0 || ^10.0.0 + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + + '@types/node@12.20.55': + resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + + '@types/semver@7.8.0': + resolution: {integrity: sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==} + + '@types/spdx-expression-parse@4.0.0': + resolution: {integrity: sha512-odQzy87phelGS4inXOzjmusx4hoCVD0IbxUANxHzVkmTzMRTNnUPoq1urIl7S1qf09KcDWKLFIftPmLtgbsAHA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + + '@typescript-eslint/eslint-plugin@8.66.0': + resolution: {integrity: sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.66.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.66.0': + resolution: {integrity: sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.66.0': + resolution: {integrity: sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.66.0': + resolution: {integrity: sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.66.0': + resolution: {integrity: sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.66.0': + resolution: {integrity: sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.65.0': + resolution: {integrity: sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/types@8.66.0': + resolution: {integrity: sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.66.0': + resolution: {integrity: sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.66.0': + resolution: {integrity: sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.66.0': + resolution: {integrity: sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@typescript/typescript6@6.0.2': + resolution: {integrity: sha512-mbCddXd+jm7hfx7w2YU64/Av4/NqqeG3GoRZgxPcgoTxYjhrcfJRw9ULch71SS4G+Q3bOXFhRvPqjguN0Hyp5w==} + hasBin: true + + '@ungap/structured-clone@1.3.3': + resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} + + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + '@vue/compiler-core@3.5.41': + resolution: {integrity: sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==} + + '@vue/compiler-dom@3.5.41': + resolution: {integrity: sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==} + + '@vue/compiler-sfc@3.5.41': + resolution: {integrity: sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==} + + '@vue/compiler-ssr@3.5.41': + resolution: {integrity: sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==} + + '@vue/devtools-api@7.7.10': + resolution: {integrity: sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==} + + '@vue/devtools-kit@7.7.10': + resolution: {integrity: sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==} + + '@vue/devtools-shared@7.7.10': + resolution: {integrity: sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==} + + '@vue/reactivity@3.5.41': + resolution: {integrity: sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==} + + '@vue/runtime-core@3.5.41': + resolution: {integrity: sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==} + + '@vue/runtime-dom@3.5.41': + resolution: {integrity: sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==} + + '@vue/server-renderer@3.5.41': + resolution: {integrity: sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==} + + '@vue/shared@3.5.41': + resolution: {integrity: sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==} + + '@vueuse/core@12.8.2': + resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==} + + '@vueuse/integrations@12.8.2': + resolution: {integrity: sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==} + peerDependencies: + async-validator: ^4 + axios: ^1 + change-case: ^5 + drauu: ^0.4 + focus-trap: ^7 + fuse.js: ^7 + idb-keyval: ^6 + jwt-decode: ^4 + nprogress: ^0.2 + qrcode: ^1.5 + sortablejs: ^1 + universal-cookie: ^7 + peerDependenciesMeta: + async-validator: + optional: true + axios: + optional: true + change-case: + optional: true + drauu: + optional: true + focus-trap: + optional: true + fuse.js: + optional: true + idb-keyval: + optional: true + jwt-decode: + optional: true + nprogress: + optional: true + qrcode: + optional: true + sortablejs: + optional: true + universal-cookie: + optional: true + + '@vueuse/metadata@12.8.2': + resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==} + + '@vueuse/shared@12.8.2': + resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + + '@yuku-codegen/binding-android-arm64@0.8.3': + resolution: {integrity: sha512-/EKnnqwvN7xYoVDhQEIEJTdPDwGW1wkFz/2Eku3ES/IJd4lcQh/OaIDFBmoJKvpe12enrb1TIoYh1fxasGXolA==} + cpu: [arm64] + os: [android] + + '@yuku-codegen/binding-darwin-arm64@0.8.3': + resolution: {integrity: sha512-DFAOliF5YIPv3ayNHGOJhIun6Af4kMaL/YXxf8ZtD1qrOIMFnX/AQBhwfvLalhwmmxuGA8AUteaKRHBvdKZFVA==} + cpu: [arm64] + os: [darwin] + + '@yuku-codegen/binding-darwin-x64@0.8.3': + resolution: {integrity: sha512-WlMh4/oEibaTzE9j5Zq8qnsrH4Ii4kWdcDv/Pj2Rb/MYSrKghtg+bxbWpPe/6zJD21p9zZBApQUxl8ECpZOJuQ==} + cpu: [x64] + os: [darwin] + + '@yuku-codegen/binding-freebsd-x64@0.8.3': + resolution: {integrity: sha512-hoDOpPP0FTxPSD+6w0Gs4p8iL1yXe6jjIXcdzNxyT1KE6B3JI6O0gTIWQISJ+8QyNpNjIwBb7nHCdRavktJM6A==} + cpu: [x64] + os: [freebsd] + + '@yuku-codegen/binding-linux-arm-gnu@0.8.3': + resolution: {integrity: sha512-nNW0GGMJyF04pK4A7Kq7WAYtUWU9uI5ugDAoXl9yHpd3IIZ8UI+zFlM01e+ZGWnQcdxYYLumeRe/EjzZT9bVfQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@yuku-codegen/binding-linux-arm-musl@0.8.3': + resolution: {integrity: sha512-/jpxKhO8AV5TmXgT3R2Gv3YctKRUhyDzd5bQw8TiJ3O4z7qerHzoW2kE40fPAO3L434/IZtZbdhr8HuOqiwECA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@yuku-codegen/binding-linux-arm64-gnu@0.8.3': + resolution: {integrity: sha512-CYhLJfnCknabfLvUjsanxC5s3BBtZHUwfzdDL7GcqShIRQh2qqgG7pPfFrFJ6Jp56kkjKXkfluFGn9nnIv0nZg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@yuku-codegen/binding-linux-arm64-musl@0.8.3': + resolution: {integrity: sha512-c6gEdnI0MgA7/rVw6CACMciSbAcxVwLyD/jSBbMLWUeqqbysCNGrGPAHdpSaadpz3W1bd+OdXt9XWjfm66708w==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@yuku-codegen/binding-linux-x64-gnu@0.8.3': + resolution: {integrity: sha512-CRVZ9Rw5lIah/PpWeShWv7XiUCMY15N6rZRA2sEZrQvc5Az7Dv9/wsDMa6oBMkfQLXuDkFo4G1QOYyWbebjejg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@yuku-codegen/binding-linux-x64-musl@0.8.3': + resolution: {integrity: sha512-G12Nhecjmv7OlbCX6Y4HU4wYYePd111kTE+yTjbitnt+P3m8bNegtYG4ZGo4scGTq8cKsLF4xcda1XNzCUA6nQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@yuku-codegen/binding-win32-arm64@0.8.3': + resolution: {integrity: sha512-i8bpXWaMlik9DvFl+89emEx3RZFtSd21Vlt0UrnPvUC7h8NGElP2SwQcdcG+pPmihFIYJAoIuJLw7YdQcFcDkA==} + cpu: [arm64] + os: [win32] + + '@yuku-codegen/binding-win32-x64@0.8.3': + resolution: {integrity: sha512-vlYymeTSsx+qxZoNvdl6KehgYDaQC4Sk/9KUnM3V2mriyCwSdhW7lqdpQGl+RLGsDTxyuRGjzGIjgRWk3lohmA==} + cpu: [x64] + os: [win32] + + '@yuku-parser/binding-android-arm64@0.8.3': + resolution: {integrity: sha512-vySYRsMeul9ssvxeHdxgS9ZUIcq7gqljWNqgokjJE0uQWvVvOprihJ6hOsiifVqWsla0BMc3vAFBvNS9QqCw7g==} + cpu: [arm64] + os: [android] + + '@yuku-parser/binding-darwin-arm64@0.8.3': + resolution: {integrity: sha512-+wpB/wqhiZ685Y77I+lj6v9pHSAJ3Y+QMHJmvch0Q0ahIMbNwtKk3s54MhtjCMKO1qpjPbyN/PjuHDg2hbKaVQ==} + cpu: [arm64] + os: [darwin] + + '@yuku-parser/binding-darwin-x64@0.8.3': + resolution: {integrity: sha512-jKqiWejj4zVy7pPtEGu4/Ty+pG1h7ooQOXIkm7shKZTSwTU9X8X+eoH11uIeKHZi2SQWV0GhNz0J56eerseysQ==} + cpu: [x64] + os: [darwin] + + '@yuku-parser/binding-freebsd-x64@0.8.3': + resolution: {integrity: sha512-FC7zSwzFzd4z9bsId07CiHLR+Iw6yW/LzIQhL5AUtPUuVXLgEyx0rilgbRUYkl1CT3GJcLpkh63WuPZUSgCDzw==} + cpu: [x64] + os: [freebsd] + + '@yuku-parser/binding-linux-arm-gnu@0.8.3': + resolution: {integrity: sha512-So61j88b9/ygDnUPlWCm1EUPw4HSxAyDjrNHKgud5N3aRDQ3kw94nW7TriXbo7GBXID9oBHCMNm1r1Fof/Df5Q==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@yuku-parser/binding-linux-arm-musl@0.8.3': + resolution: {integrity: sha512-Nmnn20yJvSSKL8ZdtqReBRSGCDkSMqR5jEk/Sk/cdIdZmqVD49Z6M7w2GbMjdrxMI1MBPbsWFMMWxa93cd5t5g==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@yuku-parser/binding-linux-arm64-gnu@0.8.3': + resolution: {integrity: sha512-Lfgw7AXJ0rxu6BMPGgfc8HLJWEIr8BHhCzcQp/75k+NM90uCLkHlBNqIg/K42KlSvBgAvu9euOvjdswib+4qJA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@yuku-parser/binding-linux-arm64-musl@0.8.3': + resolution: {integrity: sha512-cfRyu87xsJ0tFkHNsnMC4Rq6+xsFJ6i2dc4VAH52d2qLvykEJU/Mdi3ul1O2PyOApX/LoLT3uQZ0fWs3D5XE4w==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@yuku-parser/binding-linux-x64-gnu@0.8.3': + resolution: {integrity: sha512-GcQQCUuYxbm6P1n+io/A50rvWKDeWHutIp6rW0ycDOZuEQjOb8hDVgS88+NDyOnd9FfS0/Z6GXopcRFDyKpzOg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@yuku-parser/binding-linux-x64-musl@0.8.3': + resolution: {integrity: sha512-rMkImBGZzg7GZlj8krYtdiezyjYI4igjKWMut5T65jHyNWFigMQrEpn9mDIBflloW9FKhGE3mN6yTZ/N+4HRwg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@yuku-parser/binding-win32-arm64@0.8.3': + resolution: {integrity: sha512-/2Pl2cAzCXWxah8FqJapEj/ikpt9cEutEZFCa0hnbfrshkn5+C+aBM3ZDq62d1jsgQjBMmqr5HVhJUA4OAG/Tg==} + cpu: [arm64] + os: [win32] + + '@yuku-parser/binding-win32-x64@0.8.3': + resolution: {integrity: sha512-Ntnvjoan9jnfLhn7Kn3h8j/bhsbVdQSVmKUqFULKtmwImLCJVHOJbLL4qbEJyrOQ7r/FBL1/c/dRvx/AQWzzXg==} + cpu: [x64] + os: [win32] + + '@yuku-toolchain/types@0.8.3': + resolution: {integrity: sha512-9LN3HYs3A9qSPVFunsxlbfwBcUgexti3TmhOzIxB/UH8zFuaHQJXTRDcN17DW6cp1GsyZtiZA7f18uIra36Jag==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + algoliasearch@5.56.0: + resolution: {integrity: sha512-PrqppUmhT4ENdas2pH9caE7efUcxy6EcSFhWzosiVuQBzu2tQ5yLTI6jwomT/1cuBnivzGfxiJCqDNN9FRRh+Q==} + engines: {node: '>= 14.0.0'} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansis@4.3.1: + resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} + engines: {node: '>=14'} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + better-path-resolve@1.0.0: + resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} + engines: {node: '>=4'} + + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + cac@7.0.0: + resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} + engines: {node: '>=20.19.0'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + chardet@2.2.0: + resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + cjs-module-lexer@1.4.3: + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@14.0.1: + resolution: {integrity: sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==} + engines: {node: '>=20'} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + copy-anything@4.0.5: + resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + engines: {node: '>=18'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-indent@6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + dts-resolver@3.0.0: + resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==} + engines: {node: ^22.18.0 || >=24.0.0} + peerDependencies: + oxc-resolver: '>=11.0.0' + peerDependenciesMeta: + oxc-resolver: + optional: true + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + emoji-regex-xs@1.0.0: + resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} + + empathic@2.0.1: + resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} + engines: {node: '>=14'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + enquirer@2.4.1: + resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} + engines: {node: '>=8.6'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.8.0: + resolution: {integrity: sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + express-rate-limit@8.6.2: + resolution: {integrity: sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + extend-shallow@2.0.1: + resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} + engines: {node: '>=0.10.0'} + + extendable-error@0.1.7: + resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-uri@3.1.5: + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + focus-trap@7.8.0: + resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fs-extra@7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-tsconfig@5.0.0-beta.5: + resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==} + engines: {node: '>=20.20.0'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + gray-matter@4.0.3: + resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} + engines: {node: '>=6.0'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hono@4.13.2: + resolution: {integrity: sha512-JydRilDRkYBQMt9qR9U92mXxmbGqsqSn/IKOrh4e7/gEbn+0zSr8igTu0obwJoNGN4sez28DIql7FBHWydoJpA==} + engines: {node: '>=16.9.0'} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + hookable@6.1.1: + resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + human-id@4.2.0: + resolution: {integrity: sha512-K3GbkIWqyvvlpfhBPlbEvD97TtqBpAYA4kt+cn2lD2x2HuohzZCibcA2nOlnJT6exqvJLggoB5nv2dNf192nEA==} + hasBin: true + + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + + image-size@2.0.2: + resolution: {integrity: sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==} + engines: {node: '>=16.x'} + hasBin: true + + import-without-cache@0.4.0: + resolution: {integrity: sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ==} + engines: {node: ^22.18.0 || >=24.0.0} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.5.0: + resolution: {integrity: sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-extendable@0.1.1: + resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} + engines: {node: '>=0.10.0'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-subdir@1.2.0: + resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} + engines: {node: '>=4'} + + is-what@5.5.0: + resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} + engines: {node: '>=18'} + + is-windows@1.0.2: + resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + jose@6.2.9: + resolution: {integrity: sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==} + + js-yaml@3.15.0: + resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} + hasBin: true + + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + linkify-it@5.0.2: + resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} + + lint-staged@17.2.0: + resolution: {integrity: sha512-FchGnFe4i4B1C/a35SPU9bNGPEHSC1+1iV0plLjzBmKVe9klZrlRfSgK6Cw4VeHyqOXbJUXP0vON61uRftNQ0A==} + engines: {node: '>=22.22.1'} + hasBin: true + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.startcase@4.4.0: + resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + lunr@2.3.9: + resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mark.js@8.11.1: + resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} + + markdown-it@14.3.0: + resolution: {integrity: sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==} + hasBin: true + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdurl@2.1.0: + resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minisearch@7.2.0: + resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + oniguruma-to-es@3.1.1: + resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + outdent@0.5.0: + resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} + + p-filter@2.1.0: + resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} + engines: {node: '>=8'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-map@2.1.0: + resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} + engines: {node: '>=6'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-manager-detector@0.2.11: + resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} + + package-manager-detector@1.8.0: + resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + postcss@8.5.25: + resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} + engines: {node: ^10 || ^12 || >=14} + + preact@10.29.8: + resolution: {integrity: sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==} + peerDependencies: + preact-render-to-string: '>=5' + peerDependenciesMeta: + preact-render-to-string: + optional: true + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@2.8.8: + resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} + engines: {node: '>=10.13.0'} + hasBin: true + + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + publint@0.3.23: + resolution: {integrity: sha512-5MQipUPcB7MWw84zLUkHrg/H/UBtk3LL+A0GngTTBSsiNJLQurMUaSIRG3edlOrRz4UFe0AOKK9TZdIWviV+jQ==} + engines: {node: '>=18'} + hasBin: true + + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + quansync@1.0.0: + resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + read-yaml-file@1.1.0: + resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} + engines: {node: '>=6'} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rolldown-plugin-dts@0.27.14: + resolution: {integrity: sha512-ZvuDDwoIpRK9RPxDXratCpklFO9QZZWndf/sd0VBFb4LEj0jj07UcHK9OCh7V4XiFz2Z89ziyBC2K6tJiDjrbw==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@typescript/native-preview': '*' + '@volar/typescript': ~2.4.0 + rolldown: ^1.0.0 + typescript: ^5.0.0 || ^6.0.0 || ~7.0.0 + vue-tsc: ~3.2.0 || ~3.3.0 + peerDependenciesMeta: + '@typescript/native-preview': + optional: true + '@volar/typescript': + optional: true + typescript: + optional: true + vue-tsc: + optional: true + + rolldown@1.2.2: + resolution: {integrity: sha512-opwpo1tQBAcpSUJDt94B7hhLNGOKjCdE//XXjeLrnx9b83bjnw45tXdg1b09yEw/VLFBJGZpwRULMmOZo7ol+A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rollup@4.62.3: + resolution: {integrity: sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + search-insights@2.17.3: + resolution: {integrity: sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==} + + section-matter@1.0.0: + resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} + engines: {node: '>=4'} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shiki@2.5.0: + resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + smol-toml@1.8.0: + resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} + engines: {node: '>= 18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + spawndamnit@3.0.1: + resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} + + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@5.0.0: + resolution: {integrity: sha512-vngmw3Rgn+o2arXNbnZaj5UtOEBuWBfvaI+Wc8GFfykIhA5/vdK9/Sp/XkLv63dykz2rxKDvKEHupF5P0FORcQ==} + + spdx-license-ids@3.0.23: + resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + + speakingurl@14.0.1: + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} + engines: {node: '>=0.10.0'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-bom-string@1.0.0: + resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} + engines: {node: '>=0.10.0'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + + tabbable@6.5.0: + resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==} + + term-size@2.2.1: + resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} + engines: {node: '>=8'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tsdown@0.22.14: + resolution: {integrity: sha512-ule7Y+fsAN2iZbLDoo7C4KYljFJNJJ+fLshyn+9gozeTspVersWHxwdGB+Dm2hzA38s6muFnUTl0jK3vJm9ifQ==} + engines: {node: ^22.18.0 || >=24.11.0} + hasBin: true + peerDependencies: + '@arethetypeswrong/core': ^0.18.1 + '@tsdown/css': 0.22.14 + '@tsdown/exe': 0.22.14 + '@vitejs/devtools': '*' + publint: ^0.3.8 + tsx: '*' + typescript: ^5.0.0 || ^6.0.0 || ^7.0.0 + unplugin-unused: ^0.5.0 + unrun: '*' + peerDependenciesMeta: + '@arethetypeswrong/core': + optional: true + '@tsdown/css': + optional: true + '@tsdown/exe': + optional: true + '@vitejs/devtools': + optional: true + publint: + optional: true + tsx: + optional: true + typescript: + optional: true + unplugin-unused: + optional: true + unrun: + optional: true + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typedoc-plugin-markdown@4.12.0: + resolution: {integrity: sha512-eJDEMAfxCmede22c/Jw7d0FA13ggAQv+KkwQYKYCdqI02cin6Rc9QRwbG/7XvvHWinuFejySnZVUWDtvGk3Vbg==} + engines: {node: '>= 18'} + peerDependencies: + typedoc: 0.28.x + + typedoc-vitepress-theme@1.1.3: + resolution: {integrity: sha512-EK9iV7e3+R8lFNigdc0rIPWMxqfmDku0uGac3qYUu9tS4Qf1rhWZnyZJ4zu4G3iXrP5mqNPkv2wpODzRlA7jLw==} + peerDependencies: + typedoc-plugin-markdown: '>=4.11.0' + + typedoc@0.28.20: + resolution: {integrity: sha512-uSKqkh8Cr48vllnEy+jdaAgOeR6Y+QCBW7usgUsKj7gJEfR7stw9U/fE49LBnj2tPRKPY0c0EBJSWe9Appmplg==} + engines: {node: '>= 18', pnpm: '>= 10'} + hasBin: true + peerDependencies: + typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x + + typescript-eslint@8.66.0: + resolution: {integrity: sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.6.1-rc: + resolution: {integrity: sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ==} + engines: {node: '>=14.17'} + hasBin: true + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + + unconfig-core@7.5.0: + resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + validate-npm-package-name@5.0.1: + resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + verkit@0.3.2: + resolution: {integrity: sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==} + engines: {node: '>=18.12.0'} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitepress@1.6.4: + resolution: {integrity: sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==} + hasBin: true + peerDependencies: + markdown-it-mathjax3: ^4 + postcss: ^8 + peerDependenciesMeta: + markdown-it-mathjax3: + optional: true + postcss: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vue@3.5.41: + resolution: {integrity: sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yuku-ast@0.8.3: + resolution: {integrity: sha512-8x34yU5uhHUnJXzy2Qvjvec/vE9BzS0/2khVT1MsLmSLO/P8Q1Wp8IxHv+IhD+HMYETk6kherOSvP4JPWw2joQ==} + + yuku-codegen@0.8.3: + resolution: {integrity: sha512-okdo5bb+TfebQa4JOjz9QxeT34D6CcBxu8dxaPUdFEKRdLkp+D2Fah2OanepK+XTyPXdmAJzAo9iXvYvZ/5rmg==} + + yuku-parser@0.8.3: + resolution: {integrity: sha512-KPQcpF9aj77ywlJBIkQWCQ9DObdxnCA8AJdUOmA5CZZx042Xt4+dvbQmPJfWxF3E+KG5dVAZ2fBKuDJ8VsKWgA==} + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@algolia/abtesting@1.22.0': + dependencies: + '@algolia/client-common': 5.56.0 + '@algolia/requester-browser-xhr': 5.56.0 + '@algolia/requester-fetch': 5.56.0 + '@algolia/requester-node-http': 5.56.0 + + '@algolia/autocomplete-core@1.17.7(@algolia/client-search@5.56.0)(algoliasearch@5.56.0)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-plugin-algolia-insights': 1.17.7(@algolia/client-search@5.56.0)(algoliasearch@5.56.0)(search-insights@2.17.3) + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.56.0)(algoliasearch@5.56.0) + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + - search-insights + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7(@algolia/client-search@5.56.0)(algoliasearch@5.56.0)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.56.0)(algoliasearch@5.56.0) + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + + '@algolia/autocomplete-preset-algolia@1.17.7(@algolia/client-search@5.56.0)(algoliasearch@5.56.0)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.56.0)(algoliasearch@5.56.0) + '@algolia/client-search': 5.56.0 + algoliasearch: 5.56.0 + + '@algolia/autocomplete-shared@1.17.7(@algolia/client-search@5.56.0)(algoliasearch@5.56.0)': + dependencies: + '@algolia/client-search': 5.56.0 + algoliasearch: 5.56.0 + + '@algolia/client-abtesting@5.56.0': + dependencies: + '@algolia/client-common': 5.56.0 + '@algolia/requester-browser-xhr': 5.56.0 + '@algolia/requester-fetch': 5.56.0 + '@algolia/requester-node-http': 5.56.0 + + '@algolia/client-analytics@5.56.0': + dependencies: + '@algolia/client-common': 5.56.0 + '@algolia/requester-browser-xhr': 5.56.0 + '@algolia/requester-fetch': 5.56.0 + '@algolia/requester-node-http': 5.56.0 + + '@algolia/client-common@5.56.0': {} + + '@algolia/client-insights@5.56.0': + dependencies: + '@algolia/client-common': 5.56.0 + '@algolia/requester-browser-xhr': 5.56.0 + '@algolia/requester-fetch': 5.56.0 + '@algolia/requester-node-http': 5.56.0 + + '@algolia/client-personalization@5.56.0': + dependencies: + '@algolia/client-common': 5.56.0 + '@algolia/requester-browser-xhr': 5.56.0 + '@algolia/requester-fetch': 5.56.0 + '@algolia/requester-node-http': 5.56.0 + + '@algolia/client-query-suggestions@5.56.0': + dependencies: + '@algolia/client-common': 5.56.0 + '@algolia/requester-browser-xhr': 5.56.0 + '@algolia/requester-fetch': 5.56.0 + '@algolia/requester-node-http': 5.56.0 + + '@algolia/client-search@5.56.0': + dependencies: + '@algolia/client-common': 5.56.0 + '@algolia/requester-browser-xhr': 5.56.0 + '@algolia/requester-fetch': 5.56.0 + '@algolia/requester-node-http': 5.56.0 + + '@algolia/ingestion@1.56.0': + dependencies: + '@algolia/client-common': 5.56.0 + '@algolia/requester-browser-xhr': 5.56.0 + '@algolia/requester-fetch': 5.56.0 + '@algolia/requester-node-http': 5.56.0 + + '@algolia/monitoring@1.56.0': + dependencies: + '@algolia/client-common': 5.56.0 + '@algolia/requester-browser-xhr': 5.56.0 + '@algolia/requester-fetch': 5.56.0 + '@algolia/requester-node-http': 5.56.0 + + '@algolia/recommend@5.56.0': + dependencies: + '@algolia/client-common': 5.56.0 + '@algolia/requester-browser-xhr': 5.56.0 + '@algolia/requester-fetch': 5.56.0 + '@algolia/requester-node-http': 5.56.0 + + '@algolia/requester-browser-xhr@5.56.0': + dependencies: + '@algolia/client-common': 5.56.0 + + '@algolia/requester-fetch@5.56.0': + dependencies: + '@algolia/client-common': 5.56.0 + + '@algolia/requester-node-http@5.56.0': + dependencies: + '@algolia/client-common': 5.56.0 + + '@andrewbranch/untar.js@1.0.3': {} + + '@arethetypeswrong/core@0.18.5': + dependencies: + '@andrewbranch/untar.js': 1.0.3 + '@loaderkit/resolve': 1.0.6 + cjs-module-lexer: 1.4.3 + fflate: 0.8.3 + lru-cache: 11.5.2 + semver: 7.8.5 + typescript: 5.6.1-rc + validate-npm-package-name: 5.0.1 + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/runtime@7.29.7': {} + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@braidai/lang@1.1.2': {} + + '@changesets/apply-release-plan@7.1.1': + dependencies: + '@changesets/config': 3.1.4 + '@changesets/get-version-range-type': 0.4.0 + '@changesets/git': 3.0.4 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + detect-indent: 6.1.0 + fs-extra: 7.0.1 + lodash.startcase: 4.4.0 + outdent: 0.5.0 + prettier: 2.8.8 + resolve-from: 5.0.0 + semver: 7.8.5 + + '@changesets/assemble-release-plan@6.0.10': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.4 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + semver: 7.8.5 + + '@changesets/changelog-git@0.2.1': + dependencies: + '@changesets/types': 6.1.0 + + '@changesets/cli@2.31.1(@types/node@20.19.43)': + dependencies: + '@changesets/apply-release-plan': 7.1.1 + '@changesets/assemble-release-plan': 6.0.10 + '@changesets/changelog-git': 0.2.1 + '@changesets/config': 3.1.4 + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.4 + '@changesets/get-release-plan': 4.0.16 + '@changesets/git': 3.0.4 + '@changesets/logger': 0.1.1 + '@changesets/pre': 2.0.2 + '@changesets/read': 0.6.7 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@changesets/write': 0.4.0 + '@inquirer/external-editor': 1.0.3(@types/node@20.19.43) + '@manypkg/get-packages': 1.1.3 + ansi-colors: 4.1.3 + enquirer: 2.4.1 + fs-extra: 7.0.1 + mri: 1.2.0 + package-manager-detector: 0.2.11 + picocolors: 1.1.1 + resolve-from: 5.0.0 + semver: 7.8.5 + spawndamnit: 3.0.1 + term-size: 2.2.1 + transitivePeerDependencies: + - '@types/node' + + '@changesets/config@3.1.4': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.4 + '@changesets/logger': 0.1.1 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + fs-extra: 7.0.1 + micromatch: 4.0.8 + + '@changesets/errors@0.2.0': + dependencies: + extendable-error: 0.1.7 + + '@changesets/get-dependents-graph@2.1.4': + dependencies: + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + picocolors: 1.1.1 + semver: 7.8.5 + + '@changesets/get-release-plan@4.0.16': + dependencies: + '@changesets/assemble-release-plan': 6.0.10 + '@changesets/config': 3.1.4 + '@changesets/pre': 2.0.2 + '@changesets/read': 0.6.7 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + + '@changesets/get-version-range-type@0.4.0': {} + + '@changesets/git@3.0.4': + dependencies: + '@changesets/errors': 0.2.0 + '@manypkg/get-packages': 1.1.3 + is-subdir: 1.2.0 + micromatch: 4.0.8 + spawndamnit: 3.0.1 + + '@changesets/logger@0.1.1': + dependencies: + picocolors: 1.1.1 + + '@changesets/parse@0.4.3': + dependencies: + '@changesets/types': 6.1.0 + js-yaml: 4.3.1 + + '@changesets/pre@2.0.2': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + fs-extra: 7.0.1 + + '@changesets/read@0.6.7': + dependencies: + '@changesets/git': 3.0.4 + '@changesets/logger': 0.1.1 + '@changesets/parse': 0.4.3 + '@changesets/types': 6.1.0 + fs-extra: 7.0.1 + p-filter: 2.1.0 + picocolors: 1.1.1 + + '@changesets/should-skip-package@0.1.2': + dependencies: + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + + '@changesets/types@4.1.0': {} + + '@changesets/types@6.1.0': {} + + '@changesets/write@0.4.0': + dependencies: + '@changesets/types': 6.1.0 + fs-extra: 7.0.1 + human-id: 4.2.0 + prettier: 2.8.8 + + '@docsearch/css@3.8.2': {} + + '@docsearch/js@3.8.2(@algolia/client-search@5.56.0)(search-insights@2.17.3)': + dependencies: + '@docsearch/react': 3.8.2(@algolia/client-search@5.56.0)(search-insights@2.17.3) + preact: 10.29.8 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/react' + - preact-render-to-string + - react + - react-dom + - search-insights + + '@docsearch/react@3.8.2(@algolia/client-search@5.56.0)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-core': 1.17.7(@algolia/client-search@5.56.0)(algoliasearch@5.56.0)(search-insights@2.17.3) + '@algolia/autocomplete-preset-algolia': 1.17.7(@algolia/client-search@5.56.0)(algoliasearch@5.56.0) + '@docsearch/css': 3.8.2 + algoliasearch: 5.56.0 + optionalDependencies: + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@eslint-community/eslint-utils@4.10.1(eslint@10.8.0(jiti@2.7.0))': + dependencies: + eslint: 10.8.0(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.6 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.7.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.8.0(jiti@2.7.0))': + optionalDependencies: + eslint: 10.8.0(jiti@2.7.0) + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.2': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@gerrit0/mini-shiki@3.23.0': + dependencies: + '@shikijs/engine-oniguruma': 3.23.0 + '@shikijs/langs': 3.23.0 + '@shikijs/themes': 3.23.0 + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@hono/node-server@2.1.1(hono@4.13.2)': + dependencies: + hono: 4.13.2 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@iconify-json/simple-icons@1.2.93': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/types@2.0.0': {} + + '@inquirer/ansi@2.0.7': {} + + '@inquirer/checkbox@5.2.1(@types/node@20.19.43)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@20.19.43) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/confirm@6.1.1(@types/node@20.19.43)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@20.19.43) + '@inquirer/type': 4.0.7(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/core@11.2.1(@types/node@20.19.43)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@20.19.43) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.2 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/editor@5.2.2(@types/node@20.19.43)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@20.19.43) + '@inquirer/external-editor': 3.0.3(@types/node@20.19.43) + '@inquirer/type': 4.0.7(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/expand@5.1.1(@types/node@20.19.43)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@20.19.43) + '@inquirer/type': 4.0.7(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/external-editor@1.0.3(@types/node@20.19.43)': + dependencies: + chardet: 2.2.0 + iconv-lite: 0.7.3 + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/external-editor@3.0.3(@types/node@20.19.43)': + dependencies: + chardet: 2.2.0 + iconv-lite: 0.7.3 + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/figures@2.0.7': {} + + '@inquirer/input@5.1.2(@types/node@20.19.43)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@20.19.43) + '@inquirer/type': 4.0.7(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/number@4.1.1(@types/node@20.19.43)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@20.19.43) + '@inquirer/type': 4.0.7(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/password@5.1.1(@types/node@20.19.43)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@20.19.43) + '@inquirer/type': 4.0.7(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/prompts@8.5.2(@types/node@20.19.43)': + dependencies: + '@inquirer/checkbox': 5.2.1(@types/node@20.19.43) + '@inquirer/confirm': 6.1.1(@types/node@20.19.43) + '@inquirer/editor': 5.2.2(@types/node@20.19.43) + '@inquirer/expand': 5.1.1(@types/node@20.19.43) + '@inquirer/input': 5.1.2(@types/node@20.19.43) + '@inquirer/number': 4.1.1(@types/node@20.19.43) + '@inquirer/password': 5.1.1(@types/node@20.19.43) + '@inquirer/rawlist': 5.3.1(@types/node@20.19.43) + '@inquirer/search': 4.2.1(@types/node@20.19.43) + '@inquirer/select': 5.2.1(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/rawlist@5.3.1(@types/node@20.19.43)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@20.19.43) + '@inquirer/type': 4.0.7(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/search@4.2.1(@types/node@20.19.43)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@20.19.43) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/select@5.2.1(@types/node@20.19.43)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@20.19.43) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/type@4.0.7(@types/node@20.19.43)': + optionalDependencies: + '@types/node': 20.19.43 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@loaderkit/resolve@1.0.6': + dependencies: + '@braidai/lang': 1.1.2 + + '@manypkg/find-root@1.1.0': + dependencies: + '@babel/runtime': 7.29.7 + '@types/node': 12.20.55 + find-up: 4.1.0 + fs-extra: 8.1.0 + + '@manypkg/get-packages@1.1.3': + dependencies: + '@babel/runtime': 7.29.7 + '@changesets/types': 4.1.0 + '@manypkg/find-root': 1.1.0 + fs-extra: 8.1.0 + globby: 11.1.0 + read-yaml-file: 1.1.0 + + '@modelcontextprotocol/sdk@1.30.0(zod@4.4.3)': + dependencies: + '@hono/node-server': 2.1.1(hono@4.13.2) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + express: 5.2.1 + express-rate-limit: 8.6.2(express@5.2.1) + hono: 4.13.2 + jose: 6.2.9 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@oxc-project/types@0.142.0': {} + + '@publint/pack@0.1.6': + dependencies: + tinyexec: 1.2.4 + + '@quansync/fs@1.0.0': + dependencies: + quansync: 1.0.0 + + '@rolldown/binding-android-arm64@1.2.2': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.2': + optional: true + + '@rolldown/binding-darwin-x64@1.2.2': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.2': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.2': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.2': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.2': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.2': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.2': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.2': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.2': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.2': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.2': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.2': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@rollup/rollup-android-arm-eabi@4.62.3': + optional: true + + '@rollup/rollup-android-arm64@4.62.3': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.3': + optional: true + + '@rollup/rollup-darwin-x64@4.62.3': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.3': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.3': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.3': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.3': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.3': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.3': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.3': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.3': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.3': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.3': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.3': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.3': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.3': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.3': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.3': + optional: true + + '@shikijs/core@2.5.0': + dependencies: + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 3.1.1 + + '@shikijs/engine-oniguruma@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/engine-oniguruma@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/langs@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/themes@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/themes@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/transformers@2.5.0': + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/types': 2.5.0 + + '@shikijs/types@2.5.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/types@3.23.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@standard-schema/spec@1.1.0': {} + + '@stylistic/eslint-plugin@5.10.0(eslint@10.8.0(jiti@2.7.0))': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)) + '@typescript-eslint/types': 8.65.0 + eslint: 10.8.0(jiti@2.7.0) + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + estraverse: 5.3.0 + picomatch: 4.0.5 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} + + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + + '@types/json-schema@7.0.15': {} + + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdurl@2.0.0': {} + + '@types/node@12.20.55': {} + + '@types/node@20.19.43': + dependencies: + undici-types: 6.21.0 + + '@types/semver@7.8.0': {} + + '@types/spdx-expression-parse@4.0.0': {} + + '@types/unist@3.0.3': {} + + '@types/web-bluetooth@0.0.21': {} + + '@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)))(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0))': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.66.0 + '@typescript-eslint/type-utils': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)) + '@typescript-eslint/utils': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)) + '@typescript-eslint/visitor-keys': 8.66.0 + eslint: 10.8.0(jiti@2.7.0) + ignore: 7.0.6 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(@typescript/typescript6@6.0.2) + typescript: '@typescript/typescript6@6.0.2' + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0))': + dependencies: + '@typescript-eslint/scope-manager': 8.66.0 + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/typescript-estree': 8.66.0(@typescript/typescript6@6.0.2) + '@typescript-eslint/visitor-keys': 8.66.0 + debug: 4.4.3 + eslint: 10.8.0(jiti@2.7.0) + typescript: '@typescript/typescript6@6.0.2' + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.66.0(@typescript/typescript6@6.0.2)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.66.0(@typescript/typescript6@6.0.2) + '@typescript-eslint/types': 8.66.0 + debug: 4.4.3 + typescript: '@typescript/typescript6@6.0.2' + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.66.0': + dependencies: + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/visitor-keys': 8.66.0 + + '@typescript-eslint/tsconfig-utils@8.66.0(@typescript/typescript6@6.0.2)': + dependencies: + typescript: '@typescript/typescript6@6.0.2' + + '@typescript-eslint/type-utils@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0))': + dependencies: + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/typescript-estree': 8.66.0(@typescript/typescript6@6.0.2) + '@typescript-eslint/utils': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)) + debug: 4.4.3 + eslint: 10.8.0(jiti@2.7.0) + ts-api-utils: 2.5.0(@typescript/typescript6@6.0.2) + typescript: '@typescript/typescript6@6.0.2' + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.65.0': {} + + '@typescript-eslint/types@8.66.0': {} + + '@typescript-eslint/typescript-estree@8.66.0(@typescript/typescript6@6.0.2)': + dependencies: + '@typescript-eslint/project-service': 8.66.0(@typescript/typescript6@6.0.2) + '@typescript-eslint/tsconfig-utils': 8.66.0(@typescript/typescript6@6.0.2) + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/visitor-keys': 8.66.0 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(@typescript/typescript6@6.0.2) + typescript: '@typescript/typescript6@6.0.2' + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0))': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.66.0 + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/typescript-estree': 8.66.0(@typescript/typescript6@6.0.2) + eslint: 10.8.0(jiti@2.7.0) + typescript: '@typescript/typescript6@6.0.2' + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.66.0': + dependencies: + '@typescript-eslint/types': 8.66.0 + eslint-visitor-keys: 5.0.1 + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@typescript/typescript6@6.0.2': + dependencies: + '@typescript/old': typescript@6.0.3 + + '@ungap/structured-clone@1.3.3': {} + + '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@20.19.43))(vue@3.5.41(@typescript/typescript6@6.0.2))': + dependencies: + vite: 5.4.21(@types/node@20.19.43) + vue: 3.5.41(@typescript/typescript6@6.0.2) + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.10(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + '@vue/compiler-core@3.5.41': + dependencies: + '@babel/parser': 7.29.8 + '@vue/shared': 3.5.41 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.41': + dependencies: + '@vue/compiler-core': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/compiler-sfc@3.5.41': + dependencies: + '@babel/parser': 7.29.8 + '@vue/compiler-core': 3.5.41 + '@vue/compiler-dom': 3.5.41 + '@vue/compiler-ssr': 3.5.41 + '@vue/shared': 3.5.41 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.25 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.41': + dependencies: + '@vue/compiler-dom': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/devtools-api@7.7.10': + dependencies: + '@vue/devtools-kit': 7.7.10 + + '@vue/devtools-kit@7.7.10': + dependencies: + '@vue/devtools-shared': 7.7.10 + birpc: 2.9.0 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 1.0.0 + speakingurl: 14.0.1 + superjson: 2.2.6 + + '@vue/devtools-shared@7.7.10': + dependencies: + rfdc: 1.4.1 + + '@vue/reactivity@3.5.41': + dependencies: + '@vue/shared': 3.5.41 + + '@vue/runtime-core@3.5.41': + dependencies: + '@vue/reactivity': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/runtime-dom@3.5.41': + dependencies: + '@vue/reactivity': 3.5.41 + '@vue/runtime-core': 3.5.41 + '@vue/shared': 3.5.41 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.41': + dependencies: + '@vue/compiler-ssr': 3.5.41 + '@vue/runtime-dom': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/shared@3.5.41': {} + + '@vueuse/core@12.8.2(@typescript/typescript6@6.0.2)': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 12.8.2 + '@vueuse/shared': 12.8.2(@typescript/typescript6@6.0.2) + vue: 3.5.41(@typescript/typescript6@6.0.2) + transitivePeerDependencies: + - typescript + + '@vueuse/integrations@12.8.2(@typescript/typescript6@6.0.2)(focus-trap@7.8.0)': + dependencies: + '@vueuse/core': 12.8.2(@typescript/typescript6@6.0.2) + '@vueuse/shared': 12.8.2(@typescript/typescript6@6.0.2) + vue: 3.5.41(@typescript/typescript6@6.0.2) + optionalDependencies: + focus-trap: 7.8.0 + transitivePeerDependencies: + - typescript + + '@vueuse/metadata@12.8.2': {} + + '@vueuse/shared@12.8.2(@typescript/typescript6@6.0.2)': + dependencies: + vue: 3.5.41(@typescript/typescript6@6.0.2) + transitivePeerDependencies: + - typescript + + '@yuku-codegen/binding-android-arm64@0.8.3': + optional: true + + '@yuku-codegen/binding-darwin-arm64@0.8.3': + optional: true + + '@yuku-codegen/binding-darwin-x64@0.8.3': + optional: true + + '@yuku-codegen/binding-freebsd-x64@0.8.3': + optional: true + + '@yuku-codegen/binding-linux-arm-gnu@0.8.3': + optional: true + + '@yuku-codegen/binding-linux-arm-musl@0.8.3': + optional: true + + '@yuku-codegen/binding-linux-arm64-gnu@0.8.3': + optional: true + + '@yuku-codegen/binding-linux-arm64-musl@0.8.3': + optional: true + + '@yuku-codegen/binding-linux-x64-gnu@0.8.3': + optional: true + + '@yuku-codegen/binding-linux-x64-musl@0.8.3': + optional: true + + '@yuku-codegen/binding-win32-arm64@0.8.3': + optional: true + + '@yuku-codegen/binding-win32-x64@0.8.3': + optional: true + + '@yuku-parser/binding-android-arm64@0.8.3': + optional: true + + '@yuku-parser/binding-darwin-arm64@0.8.3': + optional: true + + '@yuku-parser/binding-darwin-x64@0.8.3': + optional: true + + '@yuku-parser/binding-freebsd-x64@0.8.3': + optional: true + + '@yuku-parser/binding-linux-arm-gnu@0.8.3': + optional: true + + '@yuku-parser/binding-linux-arm-musl@0.8.3': + optional: true + + '@yuku-parser/binding-linux-arm64-gnu@0.8.3': + optional: true + + '@yuku-parser/binding-linux-arm64-musl@0.8.3': + optional: true + + '@yuku-parser/binding-linux-x64-gnu@0.8.3': + optional: true + + '@yuku-parser/binding-linux-x64-musl@0.8.3': + optional: true + + '@yuku-parser/binding-win32-arm64@0.8.3': + optional: true + + '@yuku-parser/binding-win32-x64@0.8.3': + optional: true + + '@yuku-toolchain/types@0.8.3': {} + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.5 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + algoliasearch@5.56.0: + dependencies: + '@algolia/abtesting': 1.22.0 + '@algolia/client-abtesting': 5.56.0 + '@algolia/client-analytics': 5.56.0 + '@algolia/client-common': 5.56.0 + '@algolia/client-insights': 5.56.0 + '@algolia/client-personalization': 5.56.0 + '@algolia/client-query-suggestions': 5.56.0 + '@algolia/client-search': 5.56.0 + '@algolia/ingestion': 1.56.0 + '@algolia/monitoring': 1.56.0 + '@algolia/recommend': 5.56.0 + '@algolia/requester-browser-xhr': 5.56.0 + '@algolia/requester-fetch': 5.56.0 + '@algolia/requester-node-http': 5.56.0 + + ansi-colors@4.1.3: {} + + ansi-regex@5.0.1: {} + + ansis@4.3.1: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + array-union@2.1.0: {} + + assertion-error@2.0.1: {} + + balanced-match@4.0.4: {} + + better-path-resolve@1.0.0: + dependencies: + is-windows: 1.0.2 + + birpc@2.9.0: {} + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + bytes@3.1.2: {} + + cac@7.0.0: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + ccount@2.0.1: {} + + chai@6.2.2: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + chardet@2.2.0: {} + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + cjs-module-lexer@1.4.3: {} + + cli-width@4.1.0: {} + + comma-separated-tokens@2.0.3: {} + + commander@14.0.1: {} + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.1.0: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + copy-anything@4.0.5: + dependencies: + is-what: 5.5.0 + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + defu@6.1.7: {} + + depd@2.0.0: {} + + dequal@2.0.3: {} + + detect-indent@6.1.0: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + dts-resolver@3.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + emoji-regex-xs@1.0.0: {} + + empathic@2.0.1: {} + + encodeurl@2.0.0: {} + + enquirer@2.4.1: + dependencies: + ansi-colors: 4.1.3 + strip-ansi: 6.0.1 + + entities@4.5.0: {} + + entities@7.0.1: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.3.1: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + esbuild@0.28.1: + optionalDependencies: + '@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 + + escape-html@1.0.3: {} + + escape-string-regexp@4.0.0: {} + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.8.0(jiti@2.7.0): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.7.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.6 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 4.2.1 + + espree@11.2.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 5.0.1 + + esprima@4.0.1: {} + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + etag@1.8.1: {} + + eventsource-parser@3.1.1: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.1 + + expect-type@1.4.0: {} + + express-rate-limit@8.6.2(express@5.2.1): + dependencies: + debug: 4.4.3 + express: 5.2.1 + ip-address: 10.5.0 + transitivePeerDependencies: + - supports-color + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + extend-shallow@2.0.1: + dependencies: + is-extendable: 0.1.1 + + extendable-error@0.1.7: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-uri@3.1.5: {} + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fflate@0.8.3: {} + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.4 + keyv: 4.5.4 + + flatted@3.4.4: {} + + focus-trap@7.8.0: + dependencies: + tabbable: 6.5.0 + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fs-extra@7.0.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@8.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-tsconfig@5.0.0-beta.5: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + gray-matter@4.0.3: + dependencies: + js-yaml: 3.15.0 + kind-of: 6.0.3 + section-matter: 1.0.0 + strip-bom-string: 1.0.0 + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hono@4.13.2: {} + + hookable@5.5.3: {} + + hookable@6.1.1: {} + + html-void-elements@3.0.0: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + human-id@4.2.0: {} + + husky@9.1.7: {} + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + ignore@7.0.6: {} + + image-size@2.0.2: {} + + import-without-cache@0.4.0: {} + + imurmurhash@0.1.4: {} + + inherits@2.0.4: {} + + ip-address@10.5.0: {} + + ipaddr.js@1.9.1: {} + + is-extendable@0.1.1: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-promise@4.0.0: {} + + is-subdir@1.2.0: + dependencies: + better-path-resolve: 1.0.0 + + is-what@5.5.0: {} + + is-windows@1.0.2: {} + + isexe@2.0.0: {} + + jiti@2.7.0: + optional: true + + jose@6.2.9: {} + + js-yaml@3.15.0: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.3.1: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kind-of@6.0.3: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + linkify-it@5.0.2: + dependencies: + uc.micro: 2.1.0 + + lint-staged@17.2.0: + dependencies: + picomatch: 4.0.5 + string-argv: 0.3.2 + tinyexec: 1.2.4 + optionalDependencies: + yaml: 2.9.0 + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.startcase@4.4.0: {} + + lru-cache@11.5.2: {} + + lunr@2.3.9: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mark.js@8.11.1: {} + + markdown-it@14.3.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.2 + mdurl: 2.1.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + math-intrinsics@1.1.0: {} + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.3 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdurl@2.1.0: {} + + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + + merge2@1.4.1: {} + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-encode@2.0.1: {} + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + minisearch@7.2.0: {} + + mitt@3.0.1: {} + + mri@1.2.0: {} + + ms@2.1.3: {} + + mute-stream@3.0.0: {} + + nanoid@3.3.16: {} + + natural-compare@1.4.0: {} + + negotiator@1.0.0: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + obug@2.1.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + oniguruma-to-es@3.1.1: + dependencies: + emoji-regex-xs: 1.0.0 + regex: 6.1.0 + regex-recursion: 6.0.2 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + outdent@0.5.0: {} + + p-filter@2.1.0: + dependencies: + p-map: 2.1.0 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-map@2.1.0: {} + + p-try@2.2.0: {} + + package-manager-detector@0.2.11: + dependencies: + quansync: 0.2.11 + + package-manager-detector@1.8.0: {} + + parseurl@1.3.3: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-to-regexp@8.4.2: {} + + path-type@4.0.0: {} + + pathe@2.0.3: {} + + perfect-debounce@1.0.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.5: {} + + pify@4.0.1: {} + + pkce-challenge@5.0.1: {} + + postcss@8.5.25: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + preact@10.29.8: {} + + prelude-ls@1.2.1: {} + + prettier@2.8.8: {} + + property-information@7.2.0: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + publint@0.3.23: + dependencies: + '@publint/pack': 0.1.6 + package-manager-detector: 1.8.0 + picocolors: 1.1.1 + sade: 1.8.1 + + punycode.js@2.3.1: {} + + punycode@2.3.1: {} + + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + quansync@0.2.11: {} + + quansync@1.0.0: {} + + queue-microtask@1.2.3: {} + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + read-yaml-file@1.1.0: + dependencies: + graceful-fs: 4.2.11 + js-yaml: 3.15.0 + pify: 4.0.1 + strip-bom: 3.0.0 + + readdirp@5.0.0: {} + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + + require-from-string@2.0.2: {} + + resolve-from@5.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + rolldown-plugin-dts@0.27.14(@typescript/typescript6@6.0.2)(rolldown@1.2.2): + dependencies: + dts-resolver: 3.0.0 + get-tsconfig: 5.0.0-beta.5 + obug: 2.1.4 + rolldown: 1.2.2 + yuku-ast: 0.8.3 + yuku-codegen: 0.8.3 + yuku-parser: 0.8.3 + optionalDependencies: + typescript: '@typescript/typescript6@6.0.2' + transitivePeerDependencies: + - oxc-resolver + + rolldown-plugin-dts@0.27.14(rolldown@1.2.2)(typescript@7.0.2): + dependencies: + dts-resolver: 3.0.0 + get-tsconfig: 5.0.0-beta.5 + obug: 2.1.4 + rolldown: 1.2.2 + yuku-ast: 0.8.3 + yuku-codegen: 0.8.3 + yuku-parser: 0.8.3 + optionalDependencies: + typescript: 7.0.2 + transitivePeerDependencies: + - oxc-resolver + + rolldown@1.2.2: + dependencies: + '@oxc-project/types': 0.142.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.2.2 + '@rolldown/binding-darwin-arm64': 1.2.2 + '@rolldown/binding-darwin-x64': 1.2.2 + '@rolldown/binding-freebsd-x64': 1.2.2 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.2 + '@rolldown/binding-linux-arm64-gnu': 1.2.2 + '@rolldown/binding-linux-arm64-musl': 1.2.2 + '@rolldown/binding-linux-ppc64-gnu': 1.2.2 + '@rolldown/binding-linux-s390x-gnu': 1.2.2 + '@rolldown/binding-linux-x64-gnu': 1.2.2 + '@rolldown/binding-linux-x64-musl': 1.2.2 + '@rolldown/binding-openharmony-arm64': 1.2.2 + '@rolldown/binding-win32-arm64-msvc': 1.2.2 + '@rolldown/binding-win32-x64-msvc': 1.2.2 + + rollup@4.62.3: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.3 + '@rollup/rollup-android-arm64': 4.62.3 + '@rollup/rollup-darwin-arm64': 4.62.3 + '@rollup/rollup-darwin-x64': 4.62.3 + '@rollup/rollup-freebsd-arm64': 4.62.3 + '@rollup/rollup-freebsd-x64': 4.62.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.3 + '@rollup/rollup-linux-arm-musleabihf': 4.62.3 + '@rollup/rollup-linux-arm64-gnu': 4.62.3 + '@rollup/rollup-linux-arm64-musl': 4.62.3 + '@rollup/rollup-linux-loong64-gnu': 4.62.3 + '@rollup/rollup-linux-loong64-musl': 4.62.3 + '@rollup/rollup-linux-ppc64-gnu': 4.62.3 + '@rollup/rollup-linux-ppc64-musl': 4.62.3 + '@rollup/rollup-linux-riscv64-gnu': 4.62.3 + '@rollup/rollup-linux-riscv64-musl': 4.62.3 + '@rollup/rollup-linux-s390x-gnu': 4.62.3 + '@rollup/rollup-linux-x64-gnu': 4.62.3 + '@rollup/rollup-linux-x64-musl': 4.62.3 + '@rollup/rollup-openbsd-x64': 4.62.3 + '@rollup/rollup-openharmony-arm64': 4.62.3 + '@rollup/rollup-win32-arm64-msvc': 4.62.3 + '@rollup/rollup-win32-ia32-msvc': 4.62.3 + '@rollup/rollup-win32-x64-gnu': 4.62.3 + '@rollup/rollup-win32-x64-msvc': 4.62.3 + fsevents: 2.3.3 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + sade@1.8.1: + dependencies: + mri: 1.2.0 + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + search-insights@2.17.3: {} + + section-matter@1.0.0: + dependencies: + extend-shallow: 2.0.1 + kind-of: 6.0.3 + + semver@7.8.5: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shiki@2.5.0: + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/langs': 2.5.0 + '@shikijs/themes': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + slash@3.0.0: {} + + smol-toml@1.8.0: {} + + source-map-js@1.2.1: {} + + space-separated-tokens@2.0.2: {} + + spawndamnit@3.0.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@5.0.0: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.23 + + spdx-license-ids@3.0.23: {} + + speakingurl@14.0.1: {} + + sprintf-js@1.0.3: {} + + stackback@0.0.2: {} + + statuses@2.0.2: {} + + std-env@4.2.0: {} + + string-argv@0.3.2: {} + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-bom-string@1.0.0: {} + + strip-bom@3.0.0: {} + + superjson@2.2.6: + dependencies: + copy-anything: 4.0.5 + + tabbable@6.5.0: {} + + term-size@2.2.1: {} + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.1: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + tree-kill@1.2.2: {} + + trim-lines@3.0.1: {} + + ts-api-utils@2.5.0(@typescript/typescript6@6.0.2): + dependencies: + typescript: '@typescript/typescript6@6.0.2' + + tsdown@0.22.14(@arethetypeswrong/core@0.18.5)(@typescript/typescript6@6.0.2)(publint@0.3.23): + dependencies: + ansis: 4.3.1 + cac: 7.0.0 + defu: 6.1.7 + empathic: 2.0.1 + hookable: 6.1.1 + import-without-cache: 0.4.0 + obug: 2.1.4 + picomatch: 4.0.5 + rolldown: 1.2.2 + rolldown-plugin-dts: 0.27.14(@typescript/typescript6@6.0.2)(rolldown@1.2.2) + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + unconfig-core: 7.5.0 + verkit: 0.3.2 + optionalDependencies: + '@arethetypeswrong/core': 0.18.5 + publint: 0.3.23 + typescript: '@typescript/typescript6@6.0.2' + transitivePeerDependencies: + - '@typescript/native-preview' + - '@volar/typescript' + - oxc-resolver + - vue-tsc + + tsdown@0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@7.0.2): + dependencies: + ansis: 4.3.1 + cac: 7.0.0 + defu: 6.1.7 + empathic: 2.0.1 + hookable: 6.1.1 + import-without-cache: 0.4.0 + obug: 2.1.4 + picomatch: 4.0.5 + rolldown: 1.2.2 + rolldown-plugin-dts: 0.27.14(rolldown@1.2.2)(typescript@7.0.2) + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + unconfig-core: 7.5.0 + verkit: 0.3.2 + optionalDependencies: + '@arethetypeswrong/core': 0.18.5 + publint: 0.3.23 + typescript: 7.0.2 + transitivePeerDependencies: + - '@typescript/native-preview' + - '@volar/typescript' + - oxc-resolver + - vue-tsc + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + + typedoc-plugin-markdown@4.12.0(typedoc@0.28.20(@typescript/typescript6@6.0.2)): + dependencies: + typedoc: 0.28.20(@typescript/typescript6@6.0.2) + + typedoc-vitepress-theme@1.1.3(typedoc-plugin-markdown@4.12.0(typedoc@0.28.20(@typescript/typescript6@6.0.2))): + dependencies: + typedoc-plugin-markdown: 4.12.0(typedoc@0.28.20(@typescript/typescript6@6.0.2)) + + typedoc@0.28.20(@typescript/typescript6@6.0.2): + dependencies: + '@gerrit0/mini-shiki': 3.23.0 + lunr: 2.3.9 + markdown-it: 14.3.0 + minimatch: 10.2.6 + typescript: '@typescript/typescript6@6.0.2' + yaml: 2.9.0 + + typescript-eslint@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)): + dependencies: + '@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)))(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)) + '@typescript-eslint/parser': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)) + '@typescript-eslint/typescript-estree': 8.66.0(@typescript/typescript6@6.0.2) + '@typescript-eslint/utils': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)) + eslint: 10.8.0(jiti@2.7.0) + typescript: '@typescript/typescript6@6.0.2' + transitivePeerDependencies: + - supports-color + + typescript@5.6.1-rc: {} + + typescript@6.0.3: {} + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + uc.micro@2.1.0: {} + + unconfig-core@7.5.0: + dependencies: + '@quansync/fs': 1.0.0 + quansync: 1.0.0 + + undici-types@6.21.0: {} + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + universalify@0.1.2: {} + + unpipe@1.0.0: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + validate-npm-package-name@5.0.1: {} + + vary@1.1.2: {} + + verkit@0.3.2: {} + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@5.4.21(@types/node@20.19.43): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.25 + rollup: 4.62.3 + optionalDependencies: + '@types/node': 20.19.43 + fsevents: 2.3.3 + + vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0): + dependencies: + esbuild: 0.28.1 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.25 + rollup: 4.62.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 20.19.43 + fsevents: 2.3.3 + jiti: 2.7.0 + yaml: 2.9.0 + + vitepress@1.6.4(@algolia/client-search@5.56.0)(@types/node@20.19.43)(@typescript/typescript6@6.0.2)(postcss@8.5.25)(search-insights@2.17.3): + dependencies: + '@docsearch/css': 3.8.2 + '@docsearch/js': 3.8.2(@algolia/client-search@5.56.0)(search-insights@2.17.3) + '@iconify-json/simple-icons': 1.2.93 + '@shikijs/core': 2.5.0 + '@shikijs/transformers': 2.5.0 + '@shikijs/types': 2.5.0 + '@types/markdown-it': 14.1.2 + '@vitejs/plugin-vue': 5.2.4(vite@5.4.21(@types/node@20.19.43))(vue@3.5.41(@typescript/typescript6@6.0.2)) + '@vue/devtools-api': 7.7.10 + '@vue/shared': 3.5.41 + '@vueuse/core': 12.8.2(@typescript/typescript6@6.0.2) + '@vueuse/integrations': 12.8.2(@typescript/typescript6@6.0.2)(focus-trap@7.8.0) + focus-trap: 7.8.0 + mark.js: 8.11.1 + minisearch: 7.2.0 + shiki: 2.5.0 + vite: 5.4.21(@types/node@20.19.43) + vue: 3.5.41(@typescript/typescript6@6.0.2) + optionalDependencies: + postcss: 8.5.25 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/node' + - '@types/react' + - async-validator + - axios + - change-case + - drauu + - fuse.js + - idb-keyval + - jwt-decode + - less + - lightningcss + - nprogress + - preact-render-to-string + - qrcode + - react + - react-dom + - sass + - sass-embedded + - search-insights + - sortablejs + - stylus + - sugarss + - terser + - typescript + - universal-cookie + + vitest@4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.43 + transitivePeerDependencies: + - msw + + vue@3.5.41(@typescript/typescript6@6.0.2): + dependencies: + '@vue/compiler-dom': 3.5.41 + '@vue/compiler-sfc': 3.5.41 + '@vue/runtime-dom': 3.5.41 + '@vue/server-renderer': 3.5.41 + '@vue/shared': 3.5.41 + optionalDependencies: + typescript: '@typescript/typescript6@6.0.2' + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + wrappy@1.0.2: {} + + xmlchars@2.2.0: {} + + yaml@2.9.0: {} + + yocto-queue@0.1.0: {} + + yuku-ast@0.8.3: + dependencies: + '@yuku-toolchain/types': 0.8.3 + + yuku-codegen@0.8.3: + dependencies: + '@yuku-toolchain/types': 0.8.3 + optionalDependencies: + '@yuku-codegen/binding-android-arm64': 0.8.3 + '@yuku-codegen/binding-darwin-arm64': 0.8.3 + '@yuku-codegen/binding-darwin-x64': 0.8.3 + '@yuku-codegen/binding-freebsd-x64': 0.8.3 + '@yuku-codegen/binding-linux-arm-gnu': 0.8.3 + '@yuku-codegen/binding-linux-arm-musl': 0.8.3 + '@yuku-codegen/binding-linux-arm64-gnu': 0.8.3 + '@yuku-codegen/binding-linux-arm64-musl': 0.8.3 + '@yuku-codegen/binding-linux-x64-gnu': 0.8.3 + '@yuku-codegen/binding-linux-x64-musl': 0.8.3 + '@yuku-codegen/binding-win32-arm64': 0.8.3 + '@yuku-codegen/binding-win32-x64': 0.8.3 + + yuku-parser@0.8.3: + dependencies: + '@yuku-toolchain/types': 0.8.3 + yuku-ast: 0.8.3 + optionalDependencies: + '@yuku-parser/binding-android-arm64': 0.8.3 + '@yuku-parser/binding-darwin-arm64': 0.8.3 + '@yuku-parser/binding-darwin-x64': 0.8.3 + '@yuku-parser/binding-freebsd-x64': 0.8.3 + '@yuku-parser/binding-linux-arm-gnu': 0.8.3 + '@yuku-parser/binding-linux-arm-musl': 0.8.3 + '@yuku-parser/binding-linux-arm64-gnu': 0.8.3 + '@yuku-parser/binding-linux-arm64-musl': 0.8.3 + '@yuku-parser/binding-linux-x64-gnu': 0.8.3 + '@yuku-parser/binding-linux-x64-musl': 0.8.3 + '@yuku-parser/binding-win32-arm64': 0.8.3 + '@yuku-parser/binding-win32-x64': 0.8.3 + + zod-to-json-schema@3.25.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + + zod@4.4.3: {} + + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..3ad5629 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,16 @@ +packages: + - packages/* + - packages/platforms/* + - packages/extensions/* + +catalog: + '@typescript/native': npm:typescript@^7.0.2 + '@types/node': ^20.19.0 + rolldown: 1.2.2 + tsdown: ^0.22.14 + vitest: ^4.1.10 + +catalogMode: manual +cleanupUnusedCatalogs: true +linkWorkspacePackages: false +saveWorkspaceProtocol: rolling diff --git a/review1.md b/review1.md new file mode 100644 index 0000000..381644a --- /dev/null +++ b/review1.md @@ -0,0 +1,205 @@ +# ACPlugin Platform Component Contribution — 独立只读架构与实现 Review + +> 评审日期:2026-08-25 +> 仓库:`/Users/zhaozhibin/WorkSpace/acplugin` +> 分支:`beta_1_0` 基线 & HEAD:`1c169af33cf4e091a1363883a12a817b2b7aed77` +> 范围:基线到当前工作树的全部未提交改动(全部已 staged,无 untracked) +> 性质:只读评审,未修改任何源码、测试、文档、Changeset、lockfile 或配置 + +## 整体结论 + +**有条件可合并**:架构边界正确、无安全/确定性缺陷、全量质量门通过;但 Platform finalization 的全部失败原因被 Core 完全脱敏,spec §6.4 要求的可识别 "unsupported platform component contribution" 错误未真正达成,且 spec §8 明确点名的若干测试(NFC collision、跨 Session/Platform origin、AssetRef 注入)未覆盖。 + +## 范围与证据 + +- 分支 `beta_1_0`,HEAD = 基线 `1c169af3`,全部改动已 staged,`git ls-files --others --exclude-standard` 为空 +- 规模:66 文件,+2129 / −162;新增 2 个文件(1 changeset、1 跨包测试) + +实际执行的验证命令与结果: + +| 命令 | 结果 | +|---|---| +| `pnpm run lint` | 通过,无输出 | +| `pnpm run typecheck` | 13 个 workspace 全部 Done | +| `pnpm run build` | 通过(attw / publint 均无问题) | +| `pnpm run test` | 全绿:core+各包 84,`@acplugin/test` 17 文件 91 例 | +| `pnpm run docs:check` | 通过(docs build + verify + playground typecheck + verify) | +| `pnpm run versions:check` | 通过,无输出 | +| `pnpm changeset status` | 9 个公开包 major | +| `git diff --check` / `--cached --check` | clean | + +补充实证:`packages/playground/dist/claude-code/plugin/.claude-plugin/plugin.json` 实际产物为 code-point 升序且正确含 `"agents": "./agents/"`;`packages/platforms/claude-code/test/golden/.claude-plugin/plugin.json` 是**完整字节 golden 且本轮未修改**(`platform.test.ts:130-132,252`)。这证伪了"`agents` 从 base 移到 finalization 会改变 manifest 字节顺序"的假设——根因是 `snapshotJson`(`security/json-snapshot.ts:109`)与 `addDocumentField`(`package/json-snapshot.ts:70`)都做键排序,两条路径字节等价。 + +## Findings + +### P1 + +#### 1. Platform finalization 的失败原因被 Core 完全丢弃,无法区分"不支持 component 贡献"与任意其它 finalization 故障 + +- **严重级别**:P1 +- **证据**: + - `packages/core/src/lifecycle/integration-sessions.ts:213-218` — `runPlatformStage` 是 `try { … } catch { reportFailure(…, message) }`,裸 `catch` 不读取任何异常内容 + - `packages/core/src/lifecycle/platform-pipeline.ts:240-241` — 固定 message `Platform "${id}" primary Package finalization failed.` + - 抛出侧:`platforms/codex/src/index.ts:76`、`antigravity/src/index.ts:62`、`pi/src/index.ts:68` 的 `throw new TypeError('Platform component contributions are not supported by …')` + - 同一路径也吞掉 Claude/Cursor/OpenCode 的全部 payload 校验错误(`claude-code/src/index.ts:57-89` 的 unknown field / 非法 ID / 枚举错误、`:101-106` 的 collision) +- **可复现**:`packages/test/test/platforms/native-component-contribution.test.ts:335-345` 已经复现——报告中只有 `code: 'PLATFORM_FINALIZE_PACKAGE_FAILED', phase: 'finalize'`,没有任何字段指向 component contribution +- **为何是实际问题(非偏好)**:spec §6.4 要求"产生稳定 `unsupported platform component contribution` 错误",§7 要求"所有诊断只出现稳定 ID、相对资源位置和 Platform/Extension owner"——当前只有一个通用 ID,Extension 作者拿到报告后无法判断是自己的 payload 非法、ID 冲突,还是 Platform 根本不支持。这三种情况的修复动作完全不同。连带后果:`claude-code/test/platform.test.ts:322-324`、`cursor/test/platform.test.ts:286-299`、`opencode/test/platform.test.ts:271-287` 的 collision 断言只能断言这个通用 code,无法证明触发的是 collision 规则而非其它校验分支。 +- **最小修复**:六个 Platform 在 `throw` 前用已有的 `context.diagnostics`(`FinalizePackageContext` 已提供)报告一条自有 code,例如 `CODEX_COMPONENT_CONTRIBUTION_UNSUPPORTED` / `CLAUDE_COMPONENT_COLLISION`。不需要改 Core:`platformHasErrors()` 已经会在 finalize 阶段发现 error 后中止。 +- **修复风险与需补测试**:低风险,纯增量诊断。需把上述三处 collision 测试与跨包 reject 测试的断言改为具体 code,否则修复无法被证明。 + +### P2 + +#### 2. Claude / Cursor / OpenCode 各自维护一份不必要的可变 session 状态 `canonicalAgentIds`,引入 createPackage → finalizePackage 的隐性阶段顺序依赖 + +- **严重级别**:P2 +- **证据**:`platforms/claude-code/src/index.ts:147,152,173,178`;`cursor/src/index.ts:102,107,125,130`;`opencode/src/index.ts:136,141,158` +- **可复现**:静态可见。`contracts/packages.ts:136-139` 的 `FinalizePackageContext extends Omit` 已经带 `project`,且 `platform-pipeline.ts:251` 确实传入了 `project: options.project` +- **为何是实际问题**:这是三份完全冗余的闭包可变状态,把"canonical Agent 有多少个"这一 finalization 阶段现成可读的信息,改成必须依赖上一阶段写入。它不是当前的 bug(`build-session.ts:169` 每轮 build 都新建 Session,DevSession 重建不会串轮),但它是评审目标点名的"隐性完成顺序依赖",且违反 Platform Session 无跨阶段隐式耦合的设计意图。 +- **最小修复**:三处各删 2 行——移除 `let canonicalAgentIds`、移除 `createPackage` 里的赋值,`finalizePackage` 解构改为 `async finalizePackage({ project, package: mergedPackage, assets })`,内部用 `project.agents.map(agent => agent.id)`。Claude 处 `:178` 的条件改为 `project.agents.length + contributed.assets.length === 0`。 +- **修复风险与需补测试**:极低,现有 golden 与 collision 测试即可回归。 + +#### 3. `componentContributors()` 绕过仓库统一的 data-boundary,对稀疏数组和 accessor 索引给出非稳定错误 + +- **严重级别**:P2 +- **证据**:`packages/core/src/services/assets.ts:145-172` — 使用裸 `Array.isArray(value)` + `value.map(...)`;对照同语义的 `packages/core/src/package/registry.ts:310` 的 `finalizationOrigins()` 使用了 `dataArrayItems()` +- **可复现**:Platform 在 finalization 传 `componentOrigins: [ , origin]`(稀疏)→ `.map` 保留 hole → 下方 `for (const contributor of contributors)` 取到 `undefined` → 抛出 `Cannot read properties of undefined (reading 'owner')`,而非框架的稳定诊断文案。传 accessor 索引则会执行调用方 getter,这正是 `snapshotJson` / `dataArrayItems` 在全仓其它位置一律拒绝的行为。 +- **为何是实际问题**:这是 Core 唯一一处 provenance 输入没有走统一边界的地方,与紧邻的 `finalizationOrigins` 行为不一致;规格要求边界对象的 getter / 稀疏数组处理一致。授权本身不会被绕过(`allowed.has()` 用 object identity),因此不是安全漏洞。 +- **最小修复**:把 `if (!Array.isArray(value)) throw` 换成 `const items = dataArrayItems(value, 'Generated Asset componentOrigins')`,后续 `.map` 改在 `items` 上。 +- **修复风险与需补测试**:低。补一条 `asset-registry.test.ts` 用例断言稀疏数组的稳定错误文案。 + +#### 4. AGENTS.md 未更新,仓库权威规范仍描述旧的 Contributor 能力边界 + +- **严重级别**:P2 +- **证据**:`AGENTS.md` 中 `grep -n "Component Contribution\|components\|finalization"` 零命中;`AGENTS.md:66-67` 仍写"Contributor 只能读取 base Package、向声明的 extension point 新增字段、追加自有 Asset 和报告兼容性",`AGENTS.md:53-62` 的固定生命周期也未提 finalization documentFields +- **为何是实际问题**:T07 Scope 第 2 条明确要求"更新 AGENTS.md 反映该正式边界"。README、llmdoc/system.md、domain-glossary、conversion-matrix、compatibility-matrix、六个 platform doc 都已同步更新,唯独作为工程唯一硬约束来源的 AGENTS.md 落后,后续任何代理读它都会得到过时的能力边界。 +- **最小修复**:在 `AGENTS.md:67` 补一句 opaque Platform Component Contribution,并在 `:62` 的生命周期文本里补 finalization documentFields。 +- **修复风险与需补测试**:无代码风险。 + +#### 5. spec §8 明确点名但未覆盖的测试缺口 + +- **严重级别**:P2 +- **证据(逐条)**: + - **NFC collision 三家全缺**(spec §8.2 第 5 条):生产代码 `claude-code/src/index.ts:100`、`cursor/src/index.ts:58`、`opencode/src/index.ts:94` 都做 `id.normalize('NFC').toLowerCase()`,但三个平台的 collision 测试只覆盖 case(Claude,`platform.test.ts:311-325`)或精确同名(Cursor `:286-299`、OpenCode `:271-287`)。`.normalize('NFC')` 整段删掉不会有任何测试失败。 + - **跨 BuildSession / 跨 Platform 的 component origin 未覆盖**(spec §8.1.6):`services/assets.ts:274` 的 `record.session !== this.#scope.token || record.platform !== platform` 两个分支无测试;`asset-registry.test.ts:93-95` 与 `package-registry.test.ts:363` 里 platform 恒为 `'target'`。 + - **AssetRef / SourceRef 塞进 component value 未覆盖**(spec §8.1.1 逐字要求):component value 路径只有 `package-registry.test.ts:233-239` 的"必须是 JSON object"。 + - **component 的异步完成顺序无关性未覆盖**(spec §8.1.3):`package-registry.test.ts:175-199` 是唯一的异步顺序用例,其 contribution 只含 `documentFields` 和 `assets`(:186-188),断言(:197-198)也只比 documents/assets。配置顺序无关性已覆盖(:130-173)。 + - **T01/T02 定向测试文件未触及**:`packages/core/test/contracts/integration-definitions.test.ts`(泛型 factory/brand shape)与 `packages/core/test/package/distribution-registry.test.ts`(继承来源不丢失)在本轮 diff 中零改动。Distribution 继承由 `claude-code/test/platform.test.ts:337-375` 间接覆盖,泛型 brand 无覆盖。 + - **Codex/Antigravity/Pi 包内无 component 测试**(spec §8.2 末段"各增加一条"):三个包的 `test/platform.test.ts` 中 `components` 零命中,reject 仅由跨包测试覆盖。 + - **"scope 外不接受 component origins"的类型断言缺失**(T02 定向测试):`sdk-api.types.ts` 全文仅 1 处新 `@ts-expect-error`(:61,默认 never),无普通 `AssetService` 拒绝 `componentOrigins` 的编译期证明。运行时有 `assets.ts:150-151` 兜底。 +- **最小修复**:按上表补齐;NFC 与 AssetRef 注入两项优先,因为它们对应的生产代码分支目前完全无保护。 + +#### 6. 跨包 reject 测试第 344 行是恒真的空断言 + +- **严重级别**:P2 +- **证据**:`packages/test/test/platforms/native-component-contribution.test.ts:340,344` — 第 340 行已断言 `report.packages` 为 `[]`,第 344 行 `report.packages.flatMap(...).some(...)` 因此恒为 `false` +- **为何是实际问题**:这行的意图是证明"不生成 `agent-*` fallback",但它没有检查任何东西。真正的证据应来自文件系统。 +- **最小修复**:改为断言 `dist//` 下不存在贡献产物,或断言目录未被创建。 + +### 建议 + +#### 7. 两个 changeset 描述内容重复 + +`.changeset/kernel-v2-sdk-boundary.md:27` 新增段落与 `.changeset/platform-component-contributions.md:11-13` 表述同一件事,CHANGELOG 会出现两段近义描述。建议只保留新 changeset 的完整描述,旧 changeset 只保留必须的 `schema version 3` 文字修正。 + +包覆盖本身正确:`hooks`/`mcp` 未出现在新 changeset 是对的(其 src 零改动,major bump 由旧 changeset 提供)。 + +## 架构评价 + +### 是否偏离"Core 通用、Platform 语义、Extension 映射"的边界:未偏离 + +- Core 侧 `grep` 无 `agent` / `tool` / `role` / `model` / Claude/Cursor/OpenCode 路径或 Platform ID 分支。`registry.ts:328-349` 的 `componentSnapshot()` 只做 subject 语法 + subject membership + `snapshotJson` + JSON-object 断言,注释明确声明不读 value 字段,代码也确实不读。 +- Platform 侧各自独立持有 union、parser、collision key、renderer、路径与 Manifest 决策。三份 `nativeAgent()` 的字段集完全不同(Claude 13 字段、Cursor 4 字段、OpenCode 6 字段 + wire map),没有相互泄漏。 +- Extension 侧 `context.base` 仍是 `PlatformBasePackageSnapshot`,`components` 只出现在 `MergedPackageSnapshot`(`contracts/packages.ts:82-85`),Extension 无法观察其它 Contribution。 + +### 是否存在过度设计:否 + +逐一评估四个新机制,每一个都对应 spec 中不可省略的约束: + +- **泛型**:是让 Platform payload type 在 Contributor 编写处生效的唯一手段,且在 Core 边界(`contracts/integrations.ts:283-287`、`lifecycle/integration-sessions.ts:22,33`)正确擦除为 `JsonObject`。 +- **origin object identity + `#componentOrigins` WeakMap**(`services/assets.ts:212`):不可省略。owner/subject 是纯文本,Platform 可以任意编造;只有 Core 私有 WeakMap 中的对象 identity 能构成授权边界。 +- **finalization point**:不可省略。Claude/Cursor 必须在合并完成后才知道该不该写 `agents`;让 Platform 在 `createPackage` 决定则无法感知 contribution,让 Extension 写则破坏 Manifest 所有权。 +- **`FinalizationAssetService` + `scope.close()`**(`platform-pipeline.ts:245,257-260`):不可省略。WeakMap 记录已含 platform+session,单看"能否伪造"确实冗余;但 scope 的真实作用是防止 Platform 在 `validatePackage` / `createDistributions` 回调里继续签发带 component provenance 的 Asset——这正是 spec §3 逐字要求的"createPackage、Extension build/contribute、Distribution 和其他 Platform 回调得到的 AssetService 不接受该字段"。 + +### 是否存在不必要复杂度:一处 + +即 Finding 2 的 `canonicalAgentIds`。 + +三个 Platform 的 `contributedAgents()` 高度重复(各约 25 行)看起来像可抽取的复杂度,但这是**有意且正确的重复**:spec §2.2 明确写"平台类型相似不构成 Core 共享模型的理由"。抽到 Core 会立刻把 Agent 语义倒灌进 Core;抽到共享包会制造 Platform 间的隐式耦合,使任一 Platform 未来收窄字段时被绑架。不建议动。 + +### 必需、不能简化的复杂度 + +- origin 的 WeakMap identity 绑定 +- finalization scope 的即时撤销 +- `snapshotJson` 的严格 JSON 边界(同时提供 deep freeze、键排序、getter/Symbol/cycle/稀疏拒绝,是 `componentValueKey` 排序稳定的前提) +- `documentSnapshot` 中 extension/finalization point 的不重叠检查(`registry.ts:149-154`,两个 owner 命名空间隔离的唯一保障) +- merge 的三级排序(owner → subject → stable JSON) + +### 最小重构方向 + +只有 Finding 2 需要重构,三个文件各删 2 行、改 1 行解构。其余保持原状。 + +## 规格符合性矩阵 + +| 规格主题 | 状态 | 证据 | 缺口/风险 | +|---|---|---|---| +| 1. strict JSON envelope、subject 验证、deep freeze、稳定排序 | 符合 | `registry.ts:328-349`(envelope + `snapshotJson`)、`:455-457`(owner→subject→JSON 三级排序) | `componentSubject()` 正则(`:286`)是 subject membership 检查之外的冗余前置校验,若既有 subject 语法更宽会误拒 | +| 2. Core 对 payload 不透明 | 符合 | Core 全量 `grep` 无 agent/tool/role/model/平台路径;`componentSnapshot` 不读 value 字段 | 未发现 | +| 3. provenance 不可伪造、跨 session/platform 拒绝 | 实现符合,测试不足 | `assets.ts:264-278`(identity + session + platform 三重校验)、`registry.ts:303-320` | 跨 session / 跨 platform 分支无测试(Finding 5) | +| 4. finalization field 只写 Platform 预声明空位、不与 extension point 重叠 | 符合 | `registry.ts:149-154`(重叠拒绝)、`:467-504`(undeclared / duplicated / add-only) | 「finalization point 指向非空字段」「跨 Document」「非 owner Platform 写入」三个分支无测试 | +| 5. 贡献 Agent 与 canonical Agent 的 case/NFC collision | 实现符合,NFC 无测试 | `claude-code/src/index.ts:100`、`cursor:58`、`opencode:94` | NFC 分支三家全无测试(Finding 5) | +| 6. Claude / Cursor / OpenCode 原生交付完整 | 符合 | 三家 union + parser + renderer + 路径;Claude/Cursor 写 Manifest finalization point,OpenCode 按 spec §6.3 不声明 | 未发现 | +| 7. Codex / Antigravity / Pi 稳定拒绝,不生成伪 fallback | 部分符合 | `codex:75-77`、`antigravity:61-63`、`pi:67-69`;跨包测试 `:335-345` 证明 `packages === []` | 错误不可识别为 component 问题(Finding 1);三包内无自有测试;无 fallback 的文件系统证据是空断言(Finding 6) | +| 8. Marketplace 继承贡献 Asset 的 hash/owner/mode/origin | 符合 | `claude-code/src/package/manifest.ts:239`(同一 AssetRef 透传);`platform.test.ts:368-373` 四项逐一断言 | 未发现 | +| 9. 无 contribution 时产物与行为不变 | 符合(已实证) | golden 字节文件本轮零改动;`platform.test.ts:252` 全字节比对,场景含 canonical agent 且无 extension | 无 before/after 直接比对测试,但 golden 未变即等价证明 | +| 10. BuildReport v3 与 `LIFECYCLE_API_VERSION === '1'` | 符合 | `contracts/reports.ts:124`、`report-builder.ts:125`;`LIFECYCLE_API_VERSION` 未改;`verify-playground.mjs:313` 已同步 | 未发现 | +| 11. docs / README / matrix / playground / Changeset 与真实行为一致 | 部分符合 | README:335、system.md:80,88、glossary、conversion-matrix、compatibility-matrix、六个 platform doc、extension-authoring 全部更新且描述准确 | **AGENTS.md 未更新**(Finding 4);两个 changeset 描述重复(Finding 7) | + +### 类型 API 与消费者体验(C 轴)单列结论 + +`packages/acplugin/dist/index.d.mts` 中 `FinalizationAssetService|PackageComponentOrigin|ContributedPackageComponent|PackageContribution` 命中 0;`dist/sdk.d.mts` 命中 2;四个 `.d.mts` 中 `*NativeAgentComponent|*PackageComponent` 命中 0。主包/SDK 边界正确,Platform 具体 payload 只从各自包根导出。`sdk-api.types.ts:61` 证明默认 `never` 在编译期拒绝写入 payload。 + +## 测试评价 + +### 已证明的行为 + +- 跨包测试是本轮最强的一环:`native-component-contribution.test.ts:13-21,43,95,145-160` 使用真实 `dist/index.mjs` 复制进临时 `node_modules`,在独立 Node ESM 子进程执行 —— Vitest 的 `resolve.alias`(`packages/test/vitest.config.ts:19-35` 确实把 9 个包名指向 `src`)对子进程不生效。**"测试只在 source alias 下成立"的风险在这个文件上不成立**;文件中唯一的包导入是第 7 行的 `import type`。 +- 三平台原生交付、路径、owner、`contributors` provenance、两次构建 `packages` 深度相等(:289) +- inspect 不提交(:302)、失败保留上次输出(:308)、DevSession 重建(:325-326) +- Claude Marketplace 对贡献 Agent 的 owner/mode/sha256/origin 四项继承(`claude-code/test/platform.test.ts:368-373`) +- 无 contribution 时 origin 不含空 `contributors`(`asset-registry.test.ts:66-71`,`toEqual` 严格断言) +- 伪造 origin(同形冻结对象)在 Asset 与 Document 两条路径均被拒(`asset-registry.test.ts:107-109`、`package-registry.test.ts:379-382`) +- 配置顺序与 contribution 内数组顺序对 merged component 序列无影响(`package-registry.test.ts:130-173`) + +### 尚未证明但规格要求的行为 + +见 Finding 5 全表:NFC collision ×3、跨 session/platform origin、AssetRef/SourceRef 注入、component 的异步顺序无关性、泛型 brand shape、finalization point 三个剩余分支、三个 unsupported 平台的包内测试、`AssetService` 拒绝 `componentOrigins` 的编译期断言。 + +### 可能产生假阳性的测试边界 + +1. 三家 collision 测试与跨包 reject 测试都只断言 `PLATFORM_FINALIZE_PACKAGE_FAILED`。任何让 `finalizePackage` 抛错的实现缺陷(例如 ID 正则误报)都会让它们继续通过。这是 Finding 1 的直接后果,也是修 Finding 1 的主要收益。 +2. `native-component-contribution.test.ts:344` 恒真(Finding 6)。 +3. `report-builder.test.ts:60-66` 用 `toMatchObject`,无法证明 `contributors` 字段不存在;该保证实际只由 `asset-registry.test.ts:66-71` 提供。 +4. dev / inspect / 失败回滚三条测试的 fixture 虽然带 component contribution,但全部文件断言指向 `skills/base/SKILL.md`(:296,303,308,313,326),从未断言 `agents/observer.md` 在这些场景下的状态。 + +## 最终建议 + +### 必须在合并前修复 + +1. **Finding 1** —— 六个 Platform 在 `throw` 前用 `context.diagnostics.report()` 发出自有稳定 code,并把三家 collision 测试与跨包 reject 测试的断言改为该具体 code。这同时消除本轮最主要的测试假阳性面。 +2. **Finding 5 中的两项高优先**:NFC collision 测试(三家生产代码的 `.normalize('NFC')` 目前完全无保护)、component value 注入 `AssetRef`/`SourceRef` 的拒绝测试(spec §8.1.1 逐字要求)。 +3. **Finding 4** —— AGENTS.md 补两句,恢复权威规范与实现一致。 + +### 可后续处理 + +- Finding 2(`canonicalAgentIds` 三处删除)—— 纯清理,无行为变化,但建议顺手做掉,成本 6 行 +- Finding 3(`componentContributors` 改用 `dataArrayItems`) +- Finding 6(第 344 行改为文件系统断言) +- Finding 5 剩余各项:跨 session/platform origin、component 异步顺序、finalization point 三个分支、三个 unsupported 平台的包内测试、`sdk-api.types.ts` 的 scope 外 `@ts-expect-error` +- Finding 7(changeset 去重) + +### 明确不建议做的"过度设计"项 + +- 不要把三家的 `contributedAgents()` / `nativeAgent()` / `agentCollisionKey()` 抽取到 Core 或新共享包。重复是 spec §2.2 的刻意选择,抽取会立刻把 Agent 语义倒灌进 Core 并制造 Platform 间耦合。 +- 不要移除 `FinalizationAssetService` 的 scope/close 机制去"简化"为纯 WeakMap 校验。虽然 WeakMap 记录已含 platform+session 足以防伪造,但 scope 撤销是阻止 Platform 在 `validatePackage`/`createDistributions` 中延续 provenance 签发的唯一手段,spec §3 逐字要求。 +- 不要为让诊断更精确而在 Core 引入 component-aware 的错误类型或阶段。Finding 1 的正确修法在 Platform 侧用现有 `context.diagnostics`,Core 不需要任何改动。 +- 不要新增 Slot、component registry、Extension 排序、claim/suppress 或 raw Manifest patch —— 当前实现已在无这些机制的前提下满足全部功能需求,spec §9 的扩展性判据成立。 diff --git a/review2.md b/review2.md new file mode 100644 index 0000000..83b628a --- /dev/null +++ b/review2.md @@ -0,0 +1,114 @@ +# 整体结论 + +一句话结论:**不建议合并**;finalization 的字段路径未经过既有无行为数据边界,实际会执行 getter 并接受稀疏数组,违反严格输入与确定性契约。 + +## 范围与证据 + +- 分支:`beta_1_0` +- HEAD / 基线:均为 `1c169af33cf4e091a1363883a12a817b2b7aed77` +- 改动:66 个已暂存文件,`+2129/-162`;无 unstaged、无 untracked。 +- 已执行且通过:`git diff --check`、`git diff --cached --check`、`pnpm run lint`、`pnpm run typecheck`、`pnpm run test`、`pnpm run build`、`pnpm run docs:check`、`pnpm run versions:check`。 +- 测试中包括 Core `26 files / 166 tests`、跨包集成 `17 files / 91 tests`;构建的 `attw`、`publint` 通过。 +- `pnpm changeset status` 显示 9 个公开包均为 major,和 Changeset 覆盖范围一致。 +- 最终复查工作树与 diff 检查仍保持上述状态。 + +## Findings + +### P0 + +未发现。 + +### P1 + +#### finalization 字段路径会执行 getter,并接受稀疏数组 + +- 严重级别:P1 +- 证据: + - `packages/core/src/package/json-snapshot.ts:15` 用 `Array.isArray`、`.some()` 和展开运算符读取未受信任路径,没有使用既有的严格数组边界。 + - 新增的 `finalizationPoints` 经 `packages/core/src/package/registry.ts:147` → `packages/core/src/package/registry.ts:105` → `snapshotFieldPath()` 进入该缺口。 + - Platform finalizer 返回的 `documentFields.path` 也经 `packages/core/src/package/registry.ts:482` 进入同一缺口。 + - 对比既有正确边界:`packages/core/src/security/data-boundary.ts:41` 会拒绝 getter、稀疏数组、Symbol、自定义字段和非标准 Array prototype。 +- 可复现方式: + - 传入下列路径作为 Platform `createPackage()` 的 `finalizationPoints` 元素,或作为 `finalizePackage().documentFields[].path`: + ```ts + let reads = 0 + const path: string[] = [] + Object.defineProperty(path, '0', { + enumerable: true, + get() { + reads += 1 + return 'agents' + }, + }) + path.length = 1 + ``` + - 对实际构建产物中的同一函数验证,结果为 `["agents"]`,且 `reads === 2`;getter 已在 Core 校验期间执行。 + - `const path: string[] = []; path.length = 1` 也会被接受并快照成 `[undefined]`。若 base point 和 finalization field 都使用该值,后续 `addDocumentField()` 会把字段写成 `"undefined"`。 +- 为什么它是实际问题,而非偏好: + - 规格要求严格 JSON/无行为边界,且 finalization 必须仅操作稳定、精确的预声明空字段。这里 Core 在“验证”时执行了集成方行为,稀疏路径还能绕过“string segment”判断,破坏 deterministic validation 和 add-only 字段契约。 + - 这不是 Asset/provenance 越权,但它是新 finalization surface 直接可达的输入验证缺陷。 +- 最小修复方案: + - 在 `snapshotFieldPath()` 先调用 `dataArrayItems(value, label)`,再对返回的密集快照检查非空和字符串 segment,冻结该安全副本。 +- 修复风险与需要补的测试: + - 风险低;仅会拒绝原本就不符合 SDK `DocumentFieldPath` 契约的数组。 + - 覆盖 base `extensionPoints`、新增 `finalizationPoints`、Extension `documentFields.path`、Platform `documentFields.path` 的 getter、稀疏数组、Symbol、自定义字段、修改 prototype 情况;断言 getter 从未执行。 + +### P2 + +#### 新增 Component API 没有经过真实 tarball 的类型消费者验证 + +- 严重级别:P2 +- 证据: + - `packages/test/test/platforms/native-component-contribution.test.ts:144` 手工复制 workspace `dist/*.mjs` 并合成 package.json;该代理不包含 `.d.mts`,也不经过 `pnpm pack`。 + - 该测试的配置在 `packages/test/test/platforms/native-component-contribution.test.ts:194` 作为运行时源码加载,并未在干净消费者中编译新的官方 payload 泛型。 + - 现有真实 pack 测试的第三方声明仅验证旧的 `AcpluginPlatform` / `AcpluginExtension` 外形:`packages/test/test/api/sdk-package-boundary.test.ts:126`;其运行时贡献仍是 `documentFields/assets`,未使用 `components` 或 `FinalizationAssetService`:`packages/test/test/api/sdk-package-boundary.test.ts:141`。 +- 可复现方式: + - 对主包以及 Claude/Cursor/OpenCode 包执行 `pnpm pack`,在 workspace 外只安装 tarball;用 `tsc` 编译一个从官方 Platform 根入口导入 `*PackageComponent`、从 `@tokenroll/acplugin/sdk` 导入 `PlatformContributor`/`FinalizationAssetService` 的第三方 Extension,再运行一次 build。 + - 当前测试集没有执行此路径。 +- 为什么它是实际问题,而非偏好: + - 规格 §8.3 明确要求“官方公开 SDK type declarations 与 packed public package 边界”。当前 runtime proxy 已很好地覆盖真实 `.mjs` 行为,但无法证明 tarball 的 declarations、exports、peer rewrite 与新泛型在干净消费者中共同可用。 +- 最小修复方案: + - 扩展现有 `sdk-package-boundary`:打包主包及三个人支持 Component 的官方 Platform;在外部 consumer 中运行 `tsc` 和一次真实 build。 +- 修复风险与需要补的测试: + - 仅增加测试时间和 fixture 维护;能捕获 declarations/export map/peer dependency 在发布边界的回归。 + +### 建议 + +未发现需要单独跟踪的纯可维护性建议。 + +## 架构评价 + +- Core 边界:符合。新增 Core 代码仅处理 opaque JSON、排序、origin identity、Asset/provenance 和 finalization point;未引入 Claude/Cursor/OpenCode、平台路径或私有 Agent wire model。Core 内既有 canonical Agent 语义不属于本次倒灌。 +- 是否过度设计:否。`WeakMap` origin registry、回调后立即撤销的 `FinalizationAssetService`、只写预声明空位的 finalization point,都是阻断伪造 provenance、跨 session/platform 重用和 Manifest 越权所需的最小安全边界。 +- 不必要复杂度:未发现双生命周期、旧 renderer、旧输出路径或为兼容首版遗留的 shim。 +- 不应简化的部分:不要删除 scoped asset service、object-identity provenance、Core codec 的 Document 最终签发,或 deterministic owner/value 排序。 +- 最小重构方向:只修复 P1 的路径快照边界并补测试;不应为此引入 Slot、registry、Extension 排序/依赖、raw Manifest patch 或 Core Agent 类型。 + +## 规格符合性矩阵 + +| 规格主题 | 状态 | 证据 | 缺口/风险 | +| --- | --- | --- | --- | +| Component strict JSON、subject、深冻、排序 | 符合 | `componentSnapshot()` + `snapshotJson()` | 无 | +| Core payload 不透明 | 符合 | Core 仅 snapshot/sort,不读取 payload 字段 | 无 | +| provenance 防伪与 scope | 符合 | WeakMap、finalization scope、回调后 close | 无 | +| finalization 空位及不与 Extension point 重叠 | 部分符合 | 点位/overlap/claim 逻辑正确 | P1:字段路径输入不严格 | +| Agent case/NFC collision | 符合 | 三个平台各自 collision key 与测试 | 无 | +| Claude/Cursor/OpenCode 原生交付 | 符合 | 各自 parser、renderer、finalize 与平台测试 | 无 | +| Codex/Antigravity/Pi 非空拒绝 | 符合 | 各自 `finalizePackage()` 显式失败及集成测试 | 无 | +| Claude Marketplace 继承 | 符合 | Marketplace 使用已验证 primary AssetRef;测试校验 hash/owner/mode/origin | 无 | +| 无 contribution 的既有行为 | 符合 | 现有平台套件、全量回归与条件化 finalization | 无 | +| Report v3、API version `'1'` | 符合 | `schemaVersion: 3`,`LIFECYCLE_API_VERSION === '1'` | 无 | +| Docs/README/matrix/Changeset | 符合 | 文档、转换矩阵、两份 Changeset 与实际平台行为一致 | 无 | +| packed consumer 边界 | 部分符合 | 已有 runtime dist proxy 与旧 SDK tarball 测试 | P2:新 API 未在 clean tarball 类型消费者覆盖 | + +## 测试评价 + +- 已证明:Core payload snapshot/排序/subject 覆盖;origin 伪造与 scope 撤销;finalization point 的常规 add-only 约束;Claude/Cursor/OpenCode 渲染、collision、Manifest;三个不支持平台的显式拒绝;Claude Marketplace inheritance;inspect、失败保留旧输出、DevSession rebuild、确定性和全量质量门。 +- 尚未证明但规格要求:新增 SDK 泛型、官方 Platform payload declarations、`FinalizationAssetService` 在真实 packed consumer 的联合类型与运行时边界(P2)。 +- 可能产生假阳性的边界:native contribution 集成测试避免了 Vitest alias,并运行真实 `dist`,这是有效证据;但它的手工 runtime proxy 不等价于 tarball 的 `.d.mts`、exports 和 peer dependency 改写。 + +## 最终建议 + +- 合并前必须修复:P1 严格字段路径边界及其四条调用路径测试。 +- 应在宣布本轮规格完成前补齐:P2 的 clean tarball TypeScript consumer 测试。 +- 明确不建议做:不要用新增 Slot/registry/排序依赖/raw Manifest patch 解决该问题;也不要移除 scoped provenance 或让 Core 引入平台 Agent 业务类型。 diff --git a/rules/claude-instructions.mdc b/rules/claude-instructions.mdc deleted file mode 100644 index 10c0594..0000000 --- a/rules/claude-instructions.mdc +++ /dev/null @@ -1,95 +0,0 @@ ---- -description: Project instructions imported from Claude Code CLAUDE.md -alwaysApply: true ---- -# acplugin 项目规范 - -## 项目概述 - -acplugin 是一个 CLI 工具,将 Claude Code 插件(Skills、Instructions、MCP、Agents、Commands、Hooks)转换为 Codex CLI、OpenCode 和 Cursor 格式。 - -## 技术栈 - -- TypeScript + Node.js (CommonJS) -- Commander.js (CLI) -- @inquirer/prompts + chalk (TUI) -- gray-matter (YAML frontmatter) -- @iarna/toml (TOML 序列化) -- vitest (测试) - -## 项目结构 - -``` -src/ -├── index.ts # CLI 入口 + 交互式 wizard -├── types.ts # 所有类型定义 -├── github.ts # GitHub 仓库下载 -├── tui.ts # TUI 交互(wizard、checkbox、彩色输出) -├── scanner/ -│ ├── claude.ts # .claude/ 项目结构扫描(导出可复用函数) -│ └── plugin.ts # .claude-plugin/ 插件格式扫描 -├── converter/ -│ ├── skill.ts # SKILL.md 转换 -│ ├── instructions.ts # CLAUDE.md → AGENTS.md / .mdc -│ ├── mcp.ts # .mcp.json → TOML / JSON -│ ├── agent.ts # Agent 定义转换(含降级策略) -│ ├── command.ts # Command 转换 -│ └── hooks.ts # Hooks 转换(含兼容性报告) -├── writer/ -│ ├── codex.ts # Codex 输出编排 -│ ├── opencode.ts # OpenCode 输出编排 -│ └── cursor.ts # Cursor 输出编排 -└── utils/ - ├── frontmatter.ts # YAML frontmatter 解析/序列化 - ├── toml.ts # TOML 工具 - └── fs.ts # 文件系统工具 -``` - -## 架构设计原则 - -- **三阶段 Pipeline**: Scanner → Converter → Writer -- **Scanner 提取可复用函数**: `scanSkillsDir()`, `scanAgentsDir()` 等被 claude.ts 和 plugin.ts 共用 -- **Converter 无副作用**: 接收数据,返回 `ConvertedFile`,不直接写文件 -- **Writer 负责编排**: 调用多个 converter,处理合并逻辑(如多个 instruction 合并为一个 AGENTS.md) -- **降级策略**: 目标平台不支持的功能降级为文档/规则,并输出 warning - -## 开发规范 - -### 添加新资源类型 -1. 在 `types.ts` 添加类型定义 -2. 在 `scanner/claude.ts` 添加扫描函数(导出为可复用) -3. 在 `scanner/plugin.ts` 集成 -4. 创建 `converter/xxx.ts`,实现三个平台的转换 -5. 在三个 `writer/*.ts` 中调用 converter -6. 添加测试 - -### 添加新目标平台 -1. 在 `types.ts` 的 `Platform` 联合类型添加新值 -2. 每个 `converter/*.ts` 添加新平台的转换逻辑 -3. 创建 `writer/newplatform.ts` -4. 在 `index.ts` 注册 -5. 在 `tui.ts` 的 `selectPlatforms()` 添加选项 -6. 添加测试 - -### Frontmatter 解析容错 -- 社区插件的 YAML frontmatter 可能格式不规范 -- `scanSkillsDir()` 和 `scanAgentsDir()` 已加 try-catch -- 解析失败时保留原始内容,frontmatter 设为空对象 - -### 测试 -- 测试文件在 `src/__tests__/` -- test-fixture/ 目录提供完整的 Claude Code 项目示例 -- 运行: `npm test` 或 `npx vitest run` -- 每个 converter 模块有独立测试文件 - -### npm 发布 -- 包名: `@disdjj/acplugin` -- 账号有 2FA,发布需要 OTP: `npm publish --access=public` -- `prepublishOnly` 自动编译 -- `files` 字段排除了 `dist/__tests__/` - -## Git 规范 - -- commit message 使用 conventional commits 格式 -- 仓库: https://github.com/TokenRollAI/acplugin -- 主分支: main diff --git a/scripts/public-packages.mjs b/scripts/public-packages.mjs new file mode 100644 index 0000000..408c52c --- /dev/null +++ b/scripts/public-packages.mjs @@ -0,0 +1,15 @@ +/** Main public facade package used by every official Integration peer. */ +export const mainPublicPackageName = '@tokenroll/acplugin'; + +/** Stable manifest order for the independently versioned public package ecosystem. */ +export const publicPackageManifestPaths = Object.freeze([ + 'packages/acplugin/package.json', + 'packages/platforms/claude-code/package.json', + 'packages/platforms/codex/package.json', + 'packages/platforms/cursor/package.json', + 'packages/platforms/antigravity/package.json', + 'packages/platforms/opencode/package.json', + 'packages/platforms/pi/package.json', + 'packages/extensions/hooks/package.json', + 'packages/extensions/mcp/package.json', +]); diff --git a/scripts/sync-ecosystem-versions.mjs b/scripts/sync-ecosystem-versions.mjs new file mode 100644 index 0000000..3619bae --- /dev/null +++ b/scripts/sync-ecosystem-versions.mjs @@ -0,0 +1,45 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath, URL } from 'node:url'; +import { publicPackageManifestPaths } from './public-packages.mjs'; + +/** Current repository root resolved independently from the invoking cwd. */ +const root = path.resolve(fileURLToPath(new URL('..', import.meta.url))); +/** Generated snapshot consumed by init, Migration and release verification. */ +const snapshotPath = path.join(root, 'packages/acplugin/src/ecosystem/versions.json'); + +/** Read and validate the fixed public package manifest catalog. */ +async function publicVersions() { + /** Prevent a catalog typo from silently reading the same manifest twice. */ + if (new Set(publicPackageManifestPaths).size !== publicPackageManifestPaths.length) + throw new Error('Public package manifest catalog contains duplicate paths.'); + /** Stable insertion order is the catalog order, not filesystem or locale order. */ + const versions = {}; + for (const relative of publicPackageManifestPaths) { + /** Public manifest content is the only editable version source. */ + const manifest = JSON.parse(await fs.readFile(path.join(root, relative), 'utf8')); + if (typeof manifest.name !== 'string' || !manifest.name.startsWith('@tokenroll/') + || typeof manifest.version !== 'string' || manifest.version.length === 0 || manifest.private === true) { + throw new Error(`Public package manifest is invalid: ${relative}`); + } + if (Object.hasOwn(versions, manifest.name)) + throw new Error(`Public package name is duplicated: ${manifest.name}`); + versions[manifest.name] = manifest.version; + } + return versions; +} + +/** Exact generated bytes include stable indentation and one trailing newline. */ +const expected = `${JSON.stringify(await publicVersions(), null, 2)}\n`; +/** The command has one explicit mutation mode and one read-only verification mode. */ +const mode = process.argv[2]; +if (mode === '--write') { + await fs.writeFile(snapshotPath, expected); +} else if (mode === '--check') { + const actual = await fs.readFile(snapshotPath, 'utf8').catch(() => ''); + if (actual !== expected) + throw new Error('Public package version snapshot is stale. Run pnpm run versions:sync.'); +} else { + throw new Error('Usage: sync-ecosystem-versions.mjs --write|--check'); +} diff --git a/scripts/verify-docs.mjs b/scripts/verify-docs.mjs new file mode 100644 index 0000000..7e8db7d --- /dev/null +++ b/scripts/verify-docs.mjs @@ -0,0 +1,90 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** 当前仓库根目录,用于定位生成输出并检查绝对路径泄漏。 */ +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +/** TypeDoc 与 VitePress 共享的 API Markdown 输出目录。 */ +const apiDirectory = path.join(root, 'packages/docs/api'); +/** 必须作为 TypeDoc package 模块出现的公开 package 及其代表 API 页面。 */ +const publicPackages = [ + { + name: '@tokenroll/acplugin', + api: 'functions/defineConfig.md', + sdk: ['functions/definePlatform.md', 'interfaces/PlatformContributor.md', 'interfaces/CompilerService.md'], + }, + { name: '@tokenroll/acplugin-platform-antigravity', api: 'functions/antigravity.md' }, + { name: '@tokenroll/acplugin-platform-claude-code', api: 'functions/claudeCode.md' }, + { name: '@tokenroll/acplugin-platform-codex', api: 'functions/codex.md' }, + { name: '@tokenroll/acplugin-platform-cursor', api: 'functions/cursor.md' }, + { name: '@tokenroll/acplugin-platform-opencode', api: 'functions/openCode.md' }, + { name: '@tokenroll/acplugin-platform-pi', api: 'functions/pi.md' }, + { name: '@tokenroll/acplugin-extension-hooks', api: 'interfaces/Hook.md' }, + { name: '@tokenroll/acplugin-extension-mcp', api: 'type-aliases/McpServer.md' }, +]; +/** 不得成为 TypeDoc package 模块的私有 workspace。 */ +const privatePackages = ['@acplugin/core', '@acplugin/test', '@acplugin/docs', '@acplugin/playground']; + +/** 递归读取目录中的全部 Markdown 和 JSON 生成物。 */ +async function readGenerated(directory) { + /** 当前目录按代码单元稳定排序后的条目。 */ + const entries = (await fs.readdir(directory, { withFileTypes: true })) + .sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0); + /** 当前子树累积的相对文件名和文本内容。 */ + const files = []; + for (const entry of entries) { + /** 当前条目的绝对路径。 */ + const file = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...await readGenerated(file)); + } else if (entry.isFile() && (entry.name.endsWith('.md') || entry.name.endsWith('.json'))) { + files.push({ file, source: await fs.readFile(file, 'utf8') }); + } + } + return files; +} + +/** 对确定性文档结构断言失败并使用稳定消息退出。 */ +function assert(condition, message) { + if (!condition) + throw new Error(message); +} + +/** 判断路径是否存在,用于验证 TypeDoc package 页面集合。 */ +async function pathExists(file) { + try { + await fs.access(file); + return true; + } catch { + return false; + } +} + +/** 验证 TypeDoc 输出只包含期望的公开 package,且不泄漏本机路径。 */ +async function main() { + /** TypeDoc 主题生成的 VitePress sidebar 文件。 */ + const sidebar = path.join(apiDirectory, 'typedoc-sidebar.json'); + await fs.access(path.join(apiDirectory, 'index.md')); + await fs.access(sidebar); + /** 用于检查 package 名和路径泄漏的完整生成文本。 */ + const files = await readGenerated(apiDirectory); + /** 合并后供 package 名和本机路径断言使用的稳定文本。 */ + const source = files.map(file => file.source).join('\n'); + for (const packageEntry of publicPackages) { + /** 当前公开 package 对应的 TypeDoc 输出目录。 */ + const packageDirectory = path.join(apiDirectory, packageEntry.name); + assert(await pathExists(path.join(packageDirectory, 'index.md')), `Generated API is missing package page ${packageEntry.name}.`); + assert(await pathExists(path.join(packageDirectory, packageEntry.api)), `Generated API is missing representative API for ${packageEntry.name}.`); + for (const sdkApi of packageEntry.sdk ?? []) + assert(await pathExists(path.join(packageDirectory, sdkApi)), `Generated API is missing SDK API ${packageEntry.name}/${sdkApi}.`); + assert(source.includes(`/api/${packageEntry.name}/`), `Generated sidebar is missing public package ${packageEntry.name}.`); + } + for (const packageName of privatePackages) { + assert(!await pathExists(path.join(apiDirectory, packageName)), `Generated API exposes private package directory ${packageName}.`); + assert(!source.includes(packageName), `Generated API exposes private package ${packageName}.`); + } + assert(!source.includes(root), 'Generated API contains the absolute workspace path.'); +} + +/** 作为脚本入口立即运行检查,让任何结构漂移以非零退出码结束。 */ +await main(); diff --git a/scripts/verify-playground.mjs b/scripts/verify-playground.mjs new file mode 100644 index 0000000..4fe91d6 --- /dev/null +++ b/scripts/verify-playground.mjs @@ -0,0 +1,877 @@ +import { execFile, spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { clearTimeout, setTimeout } from 'node:timers'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; + +/** Promise 化的子进程执行器,用于消费真实 CLI JSON。 */ +const execute = promisify(execFile); +/** 当前仓库根目录。 */ +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +/** Playground 工程根目录。 */ +const playground = path.join(root, 'packages/playground'); +/** Playground 的完整托管输出目录。 */ +const outputRoot = path.join(playground, 'dist'); +/** 已构建的真实 ACPlugin CLI 入口。 */ +const cli = path.join(root, 'packages/acplugin/dist/cli.mjs'); +/** 构建时注入、但绝不能进入报告或产物的 Secret 标记。 */ +const secretMarker = 'PLAYGROUND_SECRET_MUST_NOT_LEAK_7c2e9a'; +/** 六个官方 Platform 的固定顺序。 */ +const platforms = ['antigravity', 'claude-code', 'codex', 'cursor', 'opencode', 'pi']; +/** 四个规范 Command ID。 */ +const commands = ['init', 'prune', 'update', 'upgrade']; +/** 三个规范 Agent ID。 */ +const agents = ['investigator', 'recorder', 'reflector']; +/** Skill 中必须按原始字节复制的辅助文件。 */ +const skillAuxiliary = [ + 'assets/icon-large.svg', + 'assets/icon-small.svg', + 'references/context-continuation.md', + 'references/planning.md', + 'references/review.md', + 'references/verification.md', +]; +/** 11 个 portable Hook 的 ID 与规范事件名。 */ +const hookEvents = Object.freeze({ + 'permission-request': 'PermissionRequest', + 'post-compact': 'PostCompact', + 'post-tool-use': 'PostToolUse', + 'pre-compact': 'PreCompact', + 'pre-tool-use': 'PreToolUse', + 'session-end': 'SessionEnd', + 'session-start': 'SessionStart', + 'stop': 'Stop', + 'subagent-start': 'SubagentStart', + 'subagent-stop': 'SubagentStop', + 'user-prompt-submit': 'UserPromptSubmit', +}); +/** 每个平台对每个 portable Hook 事件的精确兼容性结论。 */ +const hookEventLevels = Object.freeze({ + 'antigravity': { + 'permission-request': 'unsupported', + 'post-compact': 'unsupported', + 'post-tool-use': 'native', + 'pre-compact': 'native', + 'pre-tool-use': 'native', + 'session-end': 'native', + 'session-start': 'native', + 'stop': 'unsupported', + 'subagent-start': 'unsupported', + 'subagent-stop': 'unsupported', + 'user-prompt-submit': 'unsupported', + }, + 'claude-code': Object.fromEntries(Object.keys(hookEvents).map(id => [id, 'native'])), + 'codex': Object.fromEntries(Object.keys(hookEvents).map(id => [id, 'native'])), + 'cursor': { + 'permission-request': 'unsupported', + 'post-compact': 'unsupported', + 'post-tool-use': 'transform', + 'pre-compact': 'transform', + 'pre-tool-use': 'transform', + 'session-end': 'transform', + 'session-start': 'transform', + 'stop': 'transform', + 'subagent-start': 'transform', + 'subagent-stop': 'transform', + 'user-prompt-submit': 'transform', + }, + 'opencode': { + 'permission-request': 'unsupported', + 'post-compact': 'native', + 'post-tool-use': 'native', + 'pre-compact': 'unsupported', + 'pre-tool-use': 'native', + 'session-end': 'degraded', + 'session-start': 'native', + 'stop': 'degraded', + 'subagent-start': 'unsupported', + 'subagent-stop': 'unsupported', + 'user-prompt-submit': 'native', + }, + 'pi': { + 'permission-request': 'unsupported', + 'post-compact': 'native', + 'post-tool-use': 'native', + 'pre-compact': 'native', + 'pre-tool-use': 'native', + 'session-end': 'native', + 'session-start': 'native', + 'stop': 'degraded', + 'subagent-start': 'unsupported', + 'subagent-stop': 'unsupported', + 'user-prompt-submit': 'native', + }, +}); + +/** 在 Playground 报告或产物不满足预期时使用稳定消息失败。 */ +function assert(condition, message) { + if (!condition) + throw new Error(message); +} + +/** 按 UTF-16 code unit 稳定排序路径和兼容性键。 */ +function compareCodeUnits(left, right) { + if (left === right) + return 0; + return left < right ? -1 : 1; +} + +/** 创建兼容性记录的唯一结构化键。 */ +function compatibilityKey(entry) { + return `${entry.platform}\0${entry.level}\0${entry.subject}\0${entry.capability}`; +} + +/** 创建不包含 level 的兼容性查询键。 */ +function compatibilityLookupKey(platform, subject, capability) { + return `${platform}\0${subject}\0${capability}`; +} + +/** 把 canonical Hook 事件名转换为报告 capability 后缀。 */ +function hookEventCapability(event) { + return event.replace(/([a-z0-9])([A-Z])/gu, '$1-$2').toLowerCase(); +} + +/** 读取 UTF-8 文件。 */ +async function readText(file) { + return fs.readFile(file, 'utf8'); +} + +/** 读取并解析 JSON 文件。 */ +async function readJson(file) { + return JSON.parse(await readText(file)); +} + +/** 判断路径是否存在,不把其他文件系统错误吞成 missing。 */ +async function exists(file) { + try { + await fs.access(file); + return true; + } catch (error) { + if (error?.code === 'ENOENT') + return false; + throw error; + } +} + +/** 递归列出目录中的普通文件,并拒绝意外符号链接。 */ +async function listFiles(directory, prefix = '') { + /** 当前目录按名称稳定排序后的条目。 */ + const entries = (await fs.readdir(directory, { withFileTypes: true })) + .sort((left, right) => compareCodeUnits(left.name, right.name)); + /** 当前子树累计的 POSIX 相对文件路径。 */ + const files = []; + for (const entry of entries) { + /** 当前条目的绝对路径。 */ + const absolute = path.join(directory, entry.name); + /** Package Asset 使用的 POSIX 相对路径。 */ + const relative = prefix === '' ? entry.name : `${prefix}/${entry.name}`; + assert(!entry.isSymbolicLink(), `Playground output contains unexpected symlink ${relative}.`); + if (entry.isDirectory()) + files.push(...await listFiles(absolute, relative)); + else { + assert(entry.isFile(), `Playground output contains non-file entry ${relative}.`); + files.push(relative); + } + } + return files; +} + +/** 对完整 dist 树建立包含路径、mode 和字节 hash 的稳定快照。 */ +async function snapshotOutput() { + /** 当前输出中所有普通文件的稳定路径。 */ + const files = await listFiles(outputRoot); + /** 每个文件的权限与字节摘要。 */ + const snapshot = []; + for (const file of files) { + /** 当前产物的绝对路径。 */ + const absolute = path.join(outputRoot, file); + /** 当前产物的权限信息。 */ + const stat = await fs.stat(absolute); + /** 当前产物的原始字节。 */ + const bytes = await fs.readFile(absolute); + snapshot.push(`${file}\0${stat.mode & 0o777}\0${createHash('sha256').update(bytes).digest('hex')}`); + } + return snapshot; +} + +/** 运行真实 CLI command,并传入构建期 Secret 泄漏探针。 */ +async function runCli(command) { + /** CLI 稳定 JSON 模式产生的标准输出。 */ + const { stdout } = await execute(process.execPath, [cli, command, '--json'], { + cwd: playground, + env: { + ...process.env, + PLAYGROUND_LOCAL_TOKEN: secretMarker, + PLAYGROUND_MCP_TENANT: secretMarker, + PLAYGROUND_MCP_TOKEN: secretMarker, + }, + maxBuffer: 16 * 1024 * 1024, + }); + return JSON.parse(stdout); +} + +/** 在不经过 shell 的子进程中运行 Hook 或 MCP 协议输入。 */ +async function runProtocol(file, arguments_, input, environment = {}) { + return new Promise((resolve, reject) => { + /** 被验证的真实生成程序。 */ + const child = spawn(process.execPath, [file, ...arguments_], { + cwd: path.dirname(file), + env: { ...process.env, ...environment }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + /** 当前协议运行的完整标准输出。 */ + let stdout = ''; + /** 当前协议运行的完整标准错误。 */ + let stderr = ''; + /** 防止损坏模板令验证器无限等待的超时。 */ + const timer = setTimeout(() => child.kill('SIGTERM'), 5_000); + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + child.once('error', (error) => { + clearTimeout(timer); + reject(error); + }); + child.once('close', (code) => { + clearTimeout(timer); + resolve({ code, stdout, stderr }); + }); + child.stdin.end(input); + }); +} + +/** 向 expected non-native 集合增加一条精确结论。 */ +function addExpected(target, platform, level, subject, capability) { + target.add(`${platform}\0${level}\0${subject}\0${capability}`); +} + +/** 从模板能力矩阵生成全部允许的 degraded/unsupported 白名单。 */ +function expectedNonNativeEntries() { + /** 只允许显式登记的非无损兼容性记录。 */ + const expected = new Set(); + for (const platform of ['antigravity', 'codex', 'pi']) { + for (const agent of agents) { + for (const capability of ['agent.capabilities', 'agent.model', 'component']) + addExpected(expected, platform, 'degraded', `agent:${agent}`, capability); + } + } + addExpected(expected, 'cursor', 'degraded', 'agent:investigator', 'agent.model'); + addExpected(expected, 'cursor', 'degraded', 'agent:recorder', 'agent.capabilities'); + addExpected(expected, 'cursor', 'degraded', 'agent:reflector', 'agent.model'); + addExpected(expected, 'opencode', 'degraded', 'agent:investigator', 'agent.model'); + addExpected(expected, 'opencode', 'degraded', 'agent:reflector', 'agent.model'); + + for (const platform of ['antigravity', 'codex', 'cursor', 'opencode']) + addExpected(expected, platform, 'degraded', 'command:init', 'argument-hint'); + /** 依赖 Agent 的 Component 降级后必须传播到转换型 Command。 */ + const dependencyCommands = { + prune: 'reflector', + update: 'investigator', + upgrade: 'recorder', + }; + for (const platform of ['antigravity', 'codex', 'pi']) { + for (const command of Object.keys(dependencyCommands)) + addExpected(expected, platform, 'degraded', `command:${command}`, 'component'); + } + + for (const platform of platforms) { + for (const [id, event] of Object.entries(hookEvents)) { + /** 当前事件在目标 Platform 的精确支持等级。 */ + const level = hookEventLevels[platform][id]; + if (level === 'degraded' || level === 'unsupported') + addExpected(expected, platform, level, `hook:${id}`, `event.${hookEventCapability(event)}`); + } + } + for (const platform of ['antigravity', 'cursor', 'opencode', 'pi']) { + for (const hook of ['pre-tool-use', 'session-start']) + addExpected(expected, platform, 'degraded', `hook:${hook}`, 'status-message'); + } + + for (const platform of ['antigravity', 'cursor']) { + addExpected(expected, platform, 'unsupported', 'mcp:local-tools', 'transport.stdio'); + addExpected(expected, platform, 'degraded', 'mcp:oauth-docs', 'auth.oauth'); + } + addExpected(expected, 'pi', 'unsupported', 'mcp:local-tools', 'transport.stdio'); + for (const id of ['oauth-docs', 'protected-docs', 'public-docs']) + addExpected(expected, 'pi', 'unsupported', `mcp:${id}`, 'transport.http'); + for (const platform of ['antigravity', 'cursor', 'opencode', 'pi']) + addExpected(expected, platform, 'unsupported', 'runtime:playground', 'node20-esm'); + assert(expected.size === 83, 'Playground verifier has an inconsistent non-native policy table.'); + return expected; +} + +/** 校验报告结构、兼容性矩阵、诊断和 Package 覆盖。 */ +function verifyReport(report, command) { + assert(report.schemaVersion === 3, 'Playground report does not use schema v3.'); + assert(report.success === true, `Playground ${command} did not succeed.`); + assert(report.command === command, `Playground ${command} report has the wrong command.`); + assert(report.committed === (command === 'build'), `Playground ${command} has the wrong committed state.`); + assert(JSON.stringify(report.platforms.map(platform => platform.id)) === JSON.stringify(platforms), 'Playground report has the wrong Platform set.'); + assert(report.platforms.every(platform => platform.selected && platform.success), 'Playground report contains an unsuccessful Platform.'); + assert(report.runtimes.length === 1 && report.runtimes[0].id === 'playground' && report.runtimes[0].built, 'Playground Runtime report is incomplete.'); + assert(report.compatibility.length === 210, 'Playground compatibility coverage changed unexpectedly.'); + assert(report.metadata.length === 66, 'Playground metadata coverage changed unexpectedly.'); + + /** 所有 compatibility 条目的唯一查询索引。 */ + const compatibility = new Map(); + for (const entry of report.compatibility) { + /** 忽略 level 后仍应唯一的精确兼容性身份。 */ + const key = compatibilityLookupKey(entry.platform, entry.subject, entry.capability); + assert(!compatibility.has(key), `Playground report duplicates compatibility ${key.replaceAll('\0', ' / ')}.`); + compatibility.set(key, entry); + } + /** 查询并校验一条必须存在的结构化兼容性记录。 */ + const expectLevel = (platform, subject, capability, level) => { + /** 当前期望记录对应的唯一查询键。 */ + const key = compatibilityLookupKey(platform, subject, capability); + /** 按查询键取得的实际兼容性记录。 */ + const entry = compatibility.get(key); + assert(entry !== undefined, `Playground report is missing ${key.replaceAll('\0', ' / ')}.`); + assert(entry.level === level, `Playground report has wrong level for ${key.replaceAll('\0', ' / ')}.`); + }; + + /** 三类 Component 在各 Platform 的主资源等级。 */ + const componentLevels = { + 'antigravity': { command: 'transform', skill: 'native', agent: 'degraded' }, + 'claude-code': { command: 'native', skill: 'native', agent: 'native' }, + 'codex': { command: 'transform', skill: 'native', agent: 'degraded' }, + 'cursor': { command: 'native', skill: 'native', agent: 'native' }, + 'opencode': { command: 'native', skill: 'native', agent: 'native' }, + 'pi': { command: 'transform', skill: 'native', agent: 'degraded' }, + }; + for (const platform of platforms) { + for (const command of commands) { + /** 转换型 Command 会继承 fallback Agent 的 Component 降级。 */ + const level = command !== 'init' && ['antigravity', 'codex', 'pi'].includes(platform) + ? 'degraded' + : componentLevels[platform].command; + expectLevel(platform, `command:${command}`, 'component', level); + } + expectLevel(platform, 'skill:project-workflow', 'component', componentLevels[platform].skill); + for (const agent of agents) + expectLevel(platform, `agent:${agent}`, 'component', componentLevels[platform].agent); + for (const [id, event] of Object.entries(hookEvents)) + expectLevel(platform, `hook:${id}`, `event.${hookEventCapability(event)}`, hookEventLevels[platform][id]); + expectLevel( + platform, + 'runtime:playground', + 'node20-esm', + platform === 'claude-code' || platform === 'codex' ? 'native' : 'unsupported', + ); + } + + /** 每个平台必须精确报告的 MCP 能力键与等级。 */ + const mcpLevels = { + 'antigravity': [ + ['local-tools', 'transport.stdio', 'unsupported'], ['oauth-docs', 'auth.oauth', 'degraded'], + ['oauth-docs', 'transport.http', 'native'], ['protected-docs', 'auth.bearer', 'native'], + ['protected-docs', 'transport.http', 'native'], ['public-docs', 'auth.none', 'native'], + ['public-docs', 'transport.http', 'native'], + ], + 'claude-code': [ + ['local-tools', 'transport.stdio', 'native'], ['oauth-docs', 'auth.oauth', 'native'], + ['oauth-docs', 'transport.http', 'native'], ['protected-docs', 'auth.bearer', 'native'], + ['protected-docs', 'transport.http', 'native'], ['public-docs', 'auth.none', 'native'], + ['public-docs', 'transport.http', 'native'], + ], + 'codex': [ + ['local-tools', 'transport.stdio', 'native'], ['oauth-docs', 'auth.oauth', 'native'], + ['oauth-docs', 'transport.http', 'native'], ['protected-docs', 'auth.bearer', 'native'], + ['protected-docs', 'transport.http', 'native'], ['public-docs', 'auth.none', 'native'], + ['public-docs', 'transport.http', 'native'], + ], + 'cursor': [ + ['local-tools', 'transport.stdio', 'unsupported'], ['oauth-docs', 'auth.oauth', 'degraded'], + ['oauth-docs', 'transport.http', 'native'], ['protected-docs', 'auth.bearer', 'native'], + ['protected-docs', 'transport.http', 'native'], ['public-docs', 'auth.none', 'native'], + ['public-docs', 'transport.http', 'native'], + ], + 'opencode': [ + ['local-tools', 'transport.stdio', 'native'], ['oauth-docs', 'auth.oauth', 'native'], + ['oauth-docs', 'transport.http', 'native'], ['protected-docs', 'auth.bearer', 'native'], + ['protected-docs', 'transport.http', 'native'], ['public-docs', 'auth.none', 'native'], + ['public-docs', 'transport.http', 'native'], + ], + 'pi': [ + ['local-tools', 'transport.stdio', 'unsupported'], ['oauth-docs', 'transport.http', 'unsupported'], + ['protected-docs', 'transport.http', 'unsupported'], ['public-docs', 'transport.http', 'unsupported'], + ], + }; + for (const platform of platforms) { + /** 当前 Platform 实际出现的 MCP 兼容性条目。 */ + const actual = report.compatibility.filter(entry => entry.platform === platform && entry.subject.startsWith('mcp:')); + assert(actual.length === mcpLevels[platform].length, `${platform} has unexpected MCP compatibility coverage.`); + for (const [id, capability, level] of mcpLevels[platform]) + expectLevel(platform, `mcp:${id}`, capability, level); + } + + /** 报告中实际出现的全部 degraded/unsupported 结构化键。 */ + const actualNonNative = new Set(report.compatibility + .filter(entry => entry.level === 'degraded' || entry.level === 'unsupported') + .map(compatibilityKey)); + /** 模板显式接受的完整非无损能力集合。 */ + const expectedNonNative = expectedNonNativeEntries(); + assert(actualNonNative.size === expectedNonNative.size, 'Playground report has an unexpected number of non-native entries.'); + for (const key of expectedNonNative) + assert(actualNonNative.has(key), `Playground report is missing accepted non-native entry ${key.replaceAll('\0', ' / ')}.`); + for (const key of actualNonNative) + assert(expectedNonNative.has(key), `Playground report contains unexpected non-native entry ${key.replaceAll('\0', ' / ')}.`); + + /** 允许的非兼容性诊断及其精确数量。 */ + const diagnosticCounts = { COMPATIBILITY_RELAXED: 83 }; + /** 按稳定 code 汇总报告诊断。 */ + const actualDiagnosticCounts = Object.fromEntries(Object.entries(Object.groupBy(report.diagnostics, item => item.code)) + .map(([code, items]) => [code, items.length])); + assert(Object.keys(actualDiagnosticCounts).length === Object.keys(diagnosticCounts).length, 'Playground diagnostics contain an unexpected code.'); + for (const [code, count] of Object.entries(diagnosticCounts)) + assert(actualDiagnosticCounts[code] === count, `Playground diagnostic ${code} has an unexpected count.`); + assert(report.diagnostics.every(item => item.severity === 'warning'), 'Playground report contains a non-warning diagnostic.'); + + /** 预期的主交付与可选 Marketplace 交付身份。 */ + const expectedUnits = [ + 'antigravity\0plugin\0primary\0plugin', + 'claude-code\0marketplace\0distribution\0marketplace', + 'claude-code\0plugin\0primary\0plugin', + 'codex\0marketplace\0distribution\0marketplace', + 'codex\0plugin\0primary\0plugin', + 'cursor\0plugin\0primary\0plugin', + 'opencode\0workspace\0primary\0workspace', + 'pi\0package\0primary\0package', + ]; + /** 实际 Package 的结构化身份。 */ + const actualUnits = report.packages.map(unit => `${unit.platform}\0${unit.id}\0${unit.role}\0${unit.type}`); + assert(JSON.stringify(actualUnits) === JSON.stringify(expectedUnits), 'Playground Package topology changed unexpectedly.'); + assert(report.packages.every(unit => unit.validated), 'Playground report contains an unvalidated Package.'); +} + +/** 校验报告 Asset 清单与 dist 中实际文件精确一致。 */ +async function verifyArtifactClosure(report) { + /** dist 只能包含本次选择的六个平台目录。 */ + const platformDirectories = (await fs.readdir(outputRoot)).sort(compareCodeUnits); + assert(JSON.stringify(platformDirectories) === JSON.stringify(platforms), 'Managed dist contains a stale or missing Platform directory.'); + for (const unit of report.packages) { + /** 当前交付单元的真实安装根。 */ + const directory = path.join(outputRoot, unit.platform, unit.id); + /** 文件系统实际 materialize 的 Asset 路径。 */ + const actual = (await listFiles(directory)).sort(compareCodeUnits); + /** 报告中经过 owner/hash 校验的 Asset 路径。 */ + const reported = unit.assets.map(asset => asset.path).sort(compareCodeUnits); + assert(JSON.stringify(actual) === JSON.stringify(reported), `${unit.platform}/${unit.id} files do not match the BuildReport Asset registry.`); + } +} + +/** 校验 Canonical Component、Skill auxiliary 和 Public 复制内容。 */ +async function verifyCanonicalOutputs() { + /** 六个平台各自的主交付安装根。 */ + const roots = { + 'antigravity': path.join(outputRoot, 'antigravity/plugin'), + 'claude-code': path.join(outputRoot, 'claude-code/plugin'), + 'codex': path.join(outputRoot, 'codex/plugin'), + 'cursor': path.join(outputRoot, 'cursor/plugin'), + 'opencode': path.join(outputRoot, 'opencode/workspace'), + 'pi': path.join(outputRoot, 'pi/package'), + }; + /** 每个平台中 Command 的最终路径函数。 */ + const commandPath = { + /** Antigravity 把 Command 转换为 Skill。 */ + 'antigravity': id => `skills/command-${id}/SKILL.md`, + /** Claude Code 保留原生 Command。 */ + 'claude-code': id => `commands/${id}.md`, + /** Codex 把 Command 转换为 Plugin-prefixed Skill。 */ + 'codex': id => `skills/acplugin-playground-${id}/SKILL.md`, + /** Cursor 保留原生 Command。 */ + 'cursor': id => `commands/${id}.md`, + /** OpenCode 把 Command 写入工作区目录。 */ + 'opencode': id => `.opencode/commands/${id}.md`, + /** Pi 把 Command 转换为 Prompt。 */ + 'pi': id => `prompts/${id}.md`, + }; + /** 每个平台中原生或 fallback Agent 的最终路径函数。 */ + const agentPath = { + /** Antigravity 把 Agent 降级为 Skill。 */ + 'antigravity': id => `skills/agent-${id}/SKILL.md`, + /** Claude Code 保留原生 Agent。 */ + 'claude-code': id => `agents/${id}.md`, + /** Codex 把 Agent 降级为 Skill。 */ + 'codex': id => `skills/agent-${id}/SKILL.md`, + /** Cursor 保留原生 Agent。 */ + 'cursor': id => `agents/${id}.md`, + /** OpenCode 把 Agent 写入工作区目录。 */ + 'opencode': id => `.opencode/agents/${id}.md`, + /** Pi 把 Agent 降级为 Skill。 */ + 'pi': id => `skills/agent-${id}/SKILL.md`, + }; + /** 每个平台中 project-workflow Skill 的最终根。 */ + const skillRoot = { + 'antigravity': 'skills/project-workflow', + 'claude-code': 'skills/project-workflow', + 'codex': 'skills/project-workflow', + 'cursor': 'skills/project-workflow', + 'opencode': '.opencode/skills/project-workflow', + 'pi': 'skills/project-workflow', + }; + + for (const platform of platforms) { + for (const command of commands) { + /** 当前 Platform 的 Command 或转换后 Skill/Prompt 内容。 */ + const content = await readText(path.join(roots[platform], commandPath[platform](command))); + assert(content.includes('description:'), `${platform} ${command} output is missing frontmatter.`); + assert(content.includes('ACPlugin capability template'), `${platform} ${command} output lost its canonical body.`); + } + /** init 是唯一带 argumentHint 和参数占位符的覆盖用例。 */ + const init = await readText(path.join(roots[platform], commandPath[platform]('init'))); + if (platform === 'claude-code' || platform === 'cursor' || platform === 'opencode' || platform === 'pi') + assert(init.includes('$ARGUMENTS'), `${platform} init did not preserve native argument substitution.`); + else + assert(init.includes('the arguments supplied with this explicit invocation'), `${platform} init did not explain transformed arguments.`); + assert(init.includes('argument-hint: ') === (platform === 'claude-code' || platform === 'pi'), `${platform} init has the wrong argument hint representation.`); + + for (const agent of agents) { + /** 当前 Platform 的原生 Agent 或 guidance fallback。 */ + const content = await readText(path.join(roots[platform], agentPath[platform](agent))); + assert(content.includes(`description:`), `${platform} ${agent} Agent output is missing metadata.`); + if (['antigravity', 'codex', 'pi'].includes(platform)) + assert(content.includes('Intended model class:') && content.includes('guidance'), `${platform} ${agent} fallback lost explicit limitations.`); + } + + /** 当前 Platform 生成的规范 Skill 主文件。 */ + const skill = await readText(path.join(roots[platform], skillRoot[platform], 'SKILL.md')); + assert(skill.includes('name: project-workflow'), `${platform} Skill is missing its generated name.`); + assert(skill.includes('Project workflow capability template'), `${platform} Skill lost its canonical body.`); + for (const auxiliary of skillAuxiliary) { + /** Canonical Skill 辅助文件原始字节。 */ + const source = await fs.readFile(path.join(playground, 'src/skills/project-workflow', auxiliary)); + /** 当前 Platform 按 owner 复制的辅助文件字节。 */ + const generated = await fs.readFile(path.join(roots[platform], skillRoot[platform], auxiliary)); + assert(source.equals(generated), `${platform} changed Skill auxiliary ${auxiliary}.`); + } + + /** Public 文件必须对所有交付单元执行逐字节复制。 */ + const publicFiles = await listFiles(path.join(playground, 'public')); + for (const file of publicFiles) { + /** 当前 Public 源文件的原始字节。 */ + const source = await fs.readFile(path.join(playground, 'public', file)); + /** 当前 Platform 复制后的 Public 文件字节。 */ + const generated = await fs.readFile(path.join(roots[platform], file)); + assert(source.equals(generated), `${platform} changed Public file ${file}.`); + } + } + + /** Claude Code 平台专属字段必须落入原生 Frontmatter。 */ + const claudeInit = await readText(path.join(roots['claude-code'], 'commands/init.md')); + assert(claudeInit.includes('allowed-tools:') && claudeInit.includes('model: sonnet'), 'Claude Code Command options were not emitted.'); + /** Codex Skill 专属展示与 policy 字段必须落入 openai.yaml。 */ + const codexMetadata = await readText(path.join(roots.codex, 'skills/project-workflow/agents/openai.yaml')); + assert(codexMetadata.includes('icon_small: ./assets/icon-small.svg'), 'Codex Skill metadata is missing icon_small.'); + assert(codexMetadata.includes('brand_color: "#FACC15"'), 'Codex Skill metadata is missing brand color.'); + assert(codexMetadata.includes('- CODEX'), 'Codex Skill metadata is missing products policy.'); + /** Cursor 只对纯读取 Agent 输出 readonly。 */ + const cursorInvestigator = await readText(path.join(roots.cursor, 'agents/investigator.md')); + /** Cursor 中拥有写权限的 recorder Agent。 */ + const cursorRecorder = await readText(path.join(roots.cursor, 'agents/recorder.md')); + assert(cursorInvestigator.includes('readonly: true'), 'Cursor read-only Agent did not preserve its capability boundary.'); + assert(!cursorRecorder.includes('readonly: true'), 'Cursor writable Agent was incorrectly marked read-only.'); + /** OpenCode 必须生成明确的工具和权限映射。 */ + const openCodeRecorder = await readText(path.join(roots.opencode, '.opencode/agents/recorder.md')); + assert(openCodeRecorder.includes('edit: true') && openCodeRecorder.includes('bash: deny'), 'OpenCode Agent capability mapping is incorrect.'); +} + +/** 校验六个平台 Manifest/Config 与两个 Marketplace 分发物。 */ +async function verifyManifestsAndDistributions() { + /** Claude Code 插件清单。 */ + const claude = await readJson(path.join(outputRoot, 'claude-code/plugin/.claude-plugin/plugin.json')); + assert(claude.commands === './commands/' && claude.skills === './skills/' && claude.agents === './agents/', 'Claude Code manifest has wrong Component references.'); + assert(claude.hooks === './hooks/hooks.json' && claude.mcpServers === './.mcp.json', 'Claude Code manifest has wrong Extension references.'); + assert(claude.defaultEnabled === false, 'Claude Code defaultEnabled option was not emitted.'); + /** Codex 插件清单。 */ + const codex = await readJson(path.join(outputRoot, 'codex/plugin/.codex-plugin/plugin.json')); + assert(codex.skills === './skills/' && codex.hooks === './hooks/hooks.json' && codex.mcpServers === './.mcp.json', 'Codex manifest has wrong resource references.'); + assert(codex.interface.brandColor === '#FACC15' && codex.interface.logo === './assets/acplugin.svg', 'Codex interface options were not emitted.'); + /** Cursor 插件清单。 */ + const cursor = await readJson(path.join(outputRoot, 'cursor/plugin/.cursor-plugin/plugin.json')); + assert(cursor.commands === './commands/*.md' && cursor.skills === './skills/*/SKILL.md' && cursor.agents === './agents/*.md', 'Cursor manifest has wrong Component globs.'); + assert(cursor.hooks === './hooks/hooks.json' && cursor.mcpServers === './mcp.json', 'Cursor manifest has wrong Extension references.'); + assert(cursor.logo === './assets/acplugin.svg' && cursor.minClientVersions.cursor === '1.0.0', 'Cursor Platform options were not emitted.'); + /** Antigravity 只允许已确认的最小 Manifest。 */ + const antigravity = await readJson(path.join(outputRoot, 'antigravity/plugin/plugin.json')); + assert(JSON.stringify(antigravity) === '{"name":"acplugin-playground"}', 'Antigravity manifest contains an unverified field.'); + /** OpenCode Workspace Config 由 schema 与 MCP add-only patch 组成。 */ + const openCode = await readJson(path.join(outputRoot, 'opencode/workspace/opencode.json')); + assert(openCode.$schema === 'https://opencode.ai/config.json' && Object.keys(openCode.mcp).length === 4, 'OpenCode workspace config is incomplete.'); + /** Pi npm package 必须保持公开包边界并发现全部资源。 */ + const pi = await readJson(path.join(outputRoot, 'pi/package/package.json')); + assert(pi.private === undefined && pi.workspaces === undefined, 'Pi package leaks workspace-only fields.'); + assert(JSON.stringify(pi.pi.extensions) === '["./extensions/acplugin-hooks.mjs"]', 'Pi package does not discover the Hooks Extension.'); + assert(pi.pi.image === './assets/acplugin.svg' && pi.pi.video.startsWith('https://'), 'Pi gallery options were not emitted.'); + assert(!Object.hasOwn(pi.pi, 'mcp'), 'Pi package fabricated unsupported MCP configuration.'); + + /** Claude Code Marketplace 根清单。 */ + const claudeMarketplace = await readJson(path.join(outputRoot, 'claude-code/marketplace/.claude-plugin/marketplace.json')); + assert(claudeMarketplace.name === 'acplugin-capability-playground-marketplace', 'Claude Code Marketplace has the wrong name.'); + assert(claudeMarketplace.plugins.length === 1 && claudeMarketplace.plugins[0].source === './' && claudeMarketplace.plugins[0].strict === true, 'Claude Code Marketplace source is not self-contained.'); + /** Codex Marketplace 根清单。 */ + const codexMarketplace = await readJson(path.join(outputRoot, 'codex/marketplace/.agents/plugins/marketplace.json')); + assert(codexMarketplace.name === 'acplugin-capability-playground-marketplace', 'Codex Marketplace has the wrong name.'); + assert(codexMarketplace.plugins.length === 1 && codexMarketplace.plugins[0].source.path === './', 'Codex Marketplace source is not self-contained.'); + + for (const platform of ['claude-code', 'codex']) { + /** 当前 Platform 主 Plugin 的全部文件。 */ + const primaryRoot = path.join(outputRoot, platform, 'plugin'); + /** 当前 Platform 自包含 Marketplace 的全部文件。 */ + const marketplaceRoot = path.join(outputRoot, platform, 'marketplace'); + for (const file of await listFiles(primaryRoot)) { + /** 主交付单元中的继承文件字节。 */ + const primary = await fs.readFile(path.join(primaryRoot, file)); + /** Marketplace 中对应文件的字节。 */ + const distributed = await fs.readFile(path.join(marketplaceRoot, file)); + assert(primary.equals(distributed), `${platform} Marketplace changed inherited Asset ${file}.`); + } + } +} + +/** 创建每个 Hook handler 使用的合法原生输入。 */ +function hookInput(event) { + /** 所有 Hook 输入共享的规范 snake_case 字段。 */ + const input = { + session_id: 'playground-session', + transcript_path: null, + cwd: '.', + hook_event_name: event, + }; + if (event === 'SessionStart') + return { ...input, source: 'startup' }; + if (event === 'SessionEnd') + return { ...input, reason: 'complete' }; + if (event === 'UserPromptSubmit') + return { ...input, prompt: 'Inspect the template.' }; + if (event === 'PreToolUse' || event === 'PermissionRequest') + return { ...input, tool_name: 'Read', tool_input: { path: 'README.md' }, tool_use_id: 'tool-1' }; + if (event === 'PostToolUse') + return { ...input, tool_name: 'Read', tool_input: { path: 'README.md' }, tool_use_id: 'tool-1', tool_response: { ok: true } }; + if (event === 'PreCompact' || event === 'PostCompact') + return { ...input, trigger: 'manual' }; + if (event === 'SubagentStart') + return { ...input, agent_id: 'agent-1', agent_type: 'investigator' }; + if (event === 'SubagentStop') + return { ...input, agent_id: 'agent-1', agent_type: 'investigator', stop_hook_active: true }; + return { ...input, stop_hook_active: true, last_assistant_message: 'Done.' }; +} + +/** 校验 Hook 配置引用、支持矩阵、自包含运行文件和真实平台协议。 */ +async function verifyHooks() { + /** 每个平台的 Hook 运行文件根。 */ + const hookRoots = { + 'antigravity': path.join(outputRoot, 'antigravity/plugin/hooks'), + 'claude-code': path.join(outputRoot, 'claude-code/plugin/hooks'), + 'codex': path.join(outputRoot, 'codex/plugin/hooks'), + 'cursor': path.join(outputRoot, 'cursor/plugin/hooks'), + 'opencode': path.join(outputRoot, 'opencode/workspace/.opencode/acplugin-hooks'), + 'pi': path.join(outputRoot, 'pi/package/extensions/acplugin-hooks'), + }; + /** 可以检查引用闭包的 Platform 配置文本。 */ + const configurationText = { + 'antigravity': await readText(path.join(outputRoot, 'antigravity/plugin/hooks.json')), + 'claude-code': await readText(path.join(outputRoot, 'claude-code/plugin/hooks/hooks.json')), + 'codex': await readText(path.join(outputRoot, 'codex/plugin/hooks/hooks.json')), + 'cursor': await readText(path.join(outputRoot, 'cursor/plugin/hooks/hooks.json')), + 'opencode': await readText(path.join(outputRoot, 'opencode/workspace/.opencode/plugins/acplugin-hooks.mjs')), + 'pi': await readText(path.join(outputRoot, 'pi/package/extensions/acplugin-hooks.mjs')), + }; + for (const platform of platforms) { + for (const [id, event] of Object.entries(hookEvents)) { + /** unsupported 事件不得留下 Handler 或配置引用。 */ + const supported = hookEventLevels[platform][id] !== 'unsupported'; + /** 当前 Platform 中 handler 的绝对路径。 */ + const handler = path.join(hookRoots[platform], id, 'handler.mjs'); + assert(await exists(handler) === supported, `${platform} has wrong Handler presence for ${id}.`); + assert(configurationText[platform].includes(id) === supported, `${platform} has wrong Hook config reference for ${id}.`); + if (!supported) + continue; + /** 自包含 Handler 必须是唯一且可执行的 JavaScript 运行文件。 */ + const handlerMode = (await fs.stat(handler)).mode & 0o777; + assert(handlerMode === 0o755, `${platform} ${id} has wrong runtime mode.`); + assert(!await exists(path.join(hookRoots[platform], id, 'wire.mjs')), `${platform} ${id} leaked a runtime external.`); + /** 使用内联的真实平台 profile 执行当前 Handler。 */ + const execution = await runProtocol(handler, [platform], JSON.stringify(hookInput(event)), { + ANTIGRAVITY_PLUGIN_ROOT: hookRoots[platform], + CLAUDE_PLUGIN_DATA: path.join(hookRoots[platform], '.data'), + CLAUDE_PLUGIN_ROOT: hookRoots[platform], + CURSOR_PLUGIN_ROOT: hookRoots[platform], + PLUGIN_DATA: path.join(hookRoots[platform], '.data'), + PLUGIN_ROOT: hookRoots[platform], + }); + assert(execution.code === 0 && execution.stderr === '', `${platform} ${id} Handler failed its real wire protocol.`); + if (execution.stdout.trim() !== '') { + /** 平台需要显式 stdout 时,结果必须是可序列化对象。 */ + const output = JSON.parse(execution.stdout); + assert(output !== null && typeof output === 'object' && !Array.isArray(output), `${platform} ${id} emitted a non-object result.`); + } + } + } +} + +/** 校验 HTTP/OAuth/Bearer/stdio MCP 映射与真实本地 Server 协议。 */ +async function verifyMcp() { + /** Claude Code 包装后的 MCP 清单。 */ + const claude = (await readJson(path.join(outputRoot, 'claude-code/plugin/.mcp.json'))).mcpServers; + /** Codex 直接使用的 MCP 清单。 */ + const codex = await readJson(path.join(outputRoot, 'codex/plugin/.mcp.json')); + /** Cursor 仅含远程服务的 MCP 清单。 */ + const cursor = (await readJson(path.join(outputRoot, 'cursor/plugin/mcp.json'))).mcpServers; + /** Antigravity 仅含远程服务的 MCP 清单。 */ + const antigravity = (await readJson(path.join(outputRoot, 'antigravity/plugin/mcp_config.json'))).mcpServers; + /** OpenCode 工作区中的 MCP 清单。 */ + const openCode = (await readJson(path.join(outputRoot, 'opencode/workspace/opencode.json'))).mcp; + for (const descriptors of [claude, codex, cursor, antigravity, openCode]) { + assert(descriptors['public-docs'].url === 'https://mcp.example.com/public-docs', 'Remote public MCP URL was mapped incorrectly.'); + assert(descriptors['oauth-docs'].url === 'https://mcp.example.com/oauth-docs', 'Remote OAuth MCP URL was mapped incorrectly.'); + assert(descriptors['protected-docs'].url === 'https://mcp.example.com/protected-docs', 'Remote Bearer MCP URL was mapped incorrectly.'); + } + assert(claude['protected-docs'].headers.Authorization === 'Bearer ${PLAYGROUND_MCP_TOKEN}', 'Claude Code Bearer env reference is incorrect.'); + assert(codex['protected-docs'].bearer_token_env_var === 'PLAYGROUND_MCP_TOKEN', 'Codex Bearer env reference is incorrect.'); + assert(cursor['protected-docs'].headers.Authorization === 'Bearer ${env:PLAYGROUND_MCP_TOKEN}', 'Cursor Bearer env reference is incorrect.'); + assert(antigravity['protected-docs'].headers.Authorization === 'Bearer ${PLAYGROUND_MCP_TOKEN}', 'Antigravity Bearer env reference is incorrect.'); + assert(openCode['protected-docs'].headers.Authorization === 'Bearer {env:PLAYGROUND_MCP_TOKEN}', 'OpenCode Bearer env reference is incorrect.'); + assert(claude['oauth-docs'].oauth.scopes === 'resources:read templates:read', 'Claude Code OAuth scopes are incorrect.'); + assert(JSON.stringify(codex['oauth-docs'].scopes) === '["resources:read","templates:read"]', 'Codex OAuth scopes are incorrect.'); + assert(openCode['oauth-docs'].oauth.scope === 'resources:read templates:read', 'OpenCode OAuth scopes are incorrect.'); + assert(cursor['oauth-docs'].scopes === undefined && antigravity['oauth-docs'].scopes === undefined, 'A degraded MCP adapter emitted unsupported OAuth scopes.'); + assert(claude['local-tools'].type === 'stdio' && codex['local-tools'].command === 'node' && openCode['local-tools'].type === 'local', 'Supported local MCP descriptors are incomplete.'); + assert(cursor['local-tools'] === undefined && antigravity['local-tools'] === undefined, 'Remote-only MCP adapter emitted local stdio configuration.'); + + /** local stdio 只允许出现在三个拥有 portable root contract 的 Platform。 */ + const servers = [ + path.join(outputRoot, 'claude-code/plugin/mcp/local-tools/server.mjs'), + path.join(outputRoot, 'codex/plugin/mcp/local-tools/server.mjs'), + path.join(outputRoot, 'opencode/workspace/.opencode/mcp/local-tools/server.mjs'), + ]; + assert(!await exists(path.join(outputRoot, 'cursor/plugin/mcp/local-tools/server.mjs')), 'Cursor emitted unsupported local MCP bundle.'); + assert(!await exists(path.join(outputRoot, 'antigravity/plugin/mcp/local-tools/server.mjs')), 'Antigravity emitted unsupported local MCP bundle.'); + assert(!await exists(path.join(outputRoot, 'pi/package/mcp')), 'Pi emitted unsupported MCP artifacts.'); + /** 三个平台必须复用同一平台中立 Bundle 字节。 */ + const serverBytes = await Promise.all(servers.map(file => fs.readFile(file))); + assert(serverBytes[0].equals(serverBytes[1]) && serverBytes[0].equals(serverBytes[2]), 'Local MCP Server Bundle differs between supported Platforms.'); + for (const server of servers) { + /** 当前本地 MCP Server Bundle 的权限位。 */ + const mode = (await fs.stat(server)).mode & 0o777; + assert(mode === 0o755, 'Local MCP Server is not executable.'); + assert(!await exists(path.join(path.dirname(server), 'THIRD_PARTY_LICENSES.txt')), 'Dependency-free MCP Server emitted a spurious license inventory.'); + /** 同时覆盖 initialize、notifications/initialized、tools/list 和 tools/call。 */ + const input = [ + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'playground-verifier', version: '1.0.0' } }, + }), + JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }), + JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }), + JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'inspect-template', arguments: {} } }), + '', + ].join('\n'); + /** 真实安装 Bundle 的 JSON-RPC 响应。 */ + const execution = await runProtocol(server, [], input); + assert(execution.code === 0 && execution.stderr === '', 'Local MCP Server failed its real protocol smoke.'); + /** 按 JSON Lines 协议解析的三条响应。 */ + const responses = execution.stdout.trim().split('\n').map(line => JSON.parse(line)); + assert(responses.length === 3, 'Local MCP Server emitted an unexpected response count.'); + assert(responses[0].result.serverInfo.name === 'acplugin-playground', 'Local MCP initialize response is incorrect.'); + assert(responses[1].result.tools[0].name === 'inspect-template', 'Local MCP tools/list response is incorrect.'); + assert(responses[2].result.content[0].text.includes('static ACPlugin capability template'), 'Local MCP tools/call response is incorrect.'); + } +} + +/** 校验 Node Runtime 的双平台同字节交付、权限、真实执行与 unsupported 空产物。 */ +async function verifyNodeRuntime() { + /** Claude Code 交付的 Runtime 可执行文件。 */ + const claude = path.join(outputRoot, 'claude-code/plugin/runtime/playground/main.mjs'); + /** Codex 交付的同一 Runtime 可执行文件。 */ + const codex = path.join(outputRoot, 'codex/plugin/runtime/playground/main.mjs'); + /** 两个平台文件必须逐字节相同,不能在 Adapter 阶段改写。 */ + const [claudeBytes, codexBytes] = await Promise.all([fs.readFile(claude), fs.readFile(codex)]); + assert(claudeBytes.equals(codexBytes), 'Node Runtime Bundle differs between Claude Code and Codex.'); + for (const runtime of [claude, codex]) { + assert(((await fs.stat(runtime)).mode & 0o777) === 0o755, 'Executable Node Runtime has the wrong mode.'); + assert(!await exists(path.join(path.dirname(runtime), 'THIRD_PARTY_LICENSES.txt')), 'Dependency-free Node Runtime emitted a spurious license inventory.'); + /** 当前平台安装文件的真实 Node 子进程执行结果。 */ + const execution = await runProtocol(runtime, [], ''); + assert(execution.code === 0 && execution.stderr === '', 'Node Runtime failed its real execution smoke.'); + assert(execution.stdout === '{"framework":"acplugin","status":"ready"}\n', 'Node Runtime emitted an unexpected result.'); + } + /** 首期没有稳定 Plugin-local Node 契约的平台输出根。 */ + const unsupported = { + antigravity: 'antigravity/plugin', + cursor: 'cursor/plugin', + opencode: 'opencode/workspace', + pi: 'pi/package', + }; + for (const [platform, unit] of Object.entries(unsupported)) { + assert( + !await exists(path.join(outputRoot, unit, 'runtime/playground/main.mjs')), + `${platform} emitted an unsupported Node Runtime Asset.`, + ); + } +} + +/** 扫描稳定报告与所有生成文件,拒绝绝对路径和 Secret 值泄漏。 */ +async function verifyStableOutputSafety(report) { + /** 报告不得包含宿主路径或构建期 Secret 值。 */ + const serializedReport = JSON.stringify(report); + assert(!serializedReport.includes(playground), 'Playground report leaks its absolute project path.'); + assert(!serializedReport.includes(secretMarker), 'Playground report leaks a build-time Secret value.'); + for (const file of await listFiles(outputRoot)) { + /** 当前输出全部是可安全按 UTF-8 扫描的 JSON/Markdown/ESM/SVG 文本。 */ + const content = await readText(path.join(outputRoot, file)); + assert(!content.includes(playground), `Generated file ${file} leaks its absolute project path.`); + assert(!content.includes(secretMarker), `Generated file ${file} leaks a build-time Secret value.`); + } +} + +/** 运行完整 Playground validate、双 build、内容协议和确定性检查。 */ +async function main() { + /** 真实 validate 产生但不提交的完整 BuildReport。 */ + const validation = await runCli('validate'); + verifyReport(validation, 'validate'); + /** 第一次真实 build 负责提交随后检查的六平台输出。 */ + const firstBuild = await runCli('build'); + verifyReport(firstBuild, 'build'); + /** validate/build 除命令和提交状态外必须拥有相同的稳定结构。 */ + for (const field of ['compatibility', 'components', 'diagnostics', 'extensions', 'metadata', 'platforms', 'runtimes']) + assert(JSON.stringify(validation[field]) === JSON.stringify(firstBuild[field]), `validate/build differ in stable report field ${field}.`); + /** command 会改变临时工作目录,但不能改变 Package 结构、size、mode、origin 或 owner。 */ + const packageShape = report => report.packages.map(unit => ({ + platform: unit.platform, + id: unit.id, + role: unit.role, + type: unit.type, + validated: unit.validated, + assets: unit.assets.map(({ sha256: _sha256, ...asset }) => asset), + })); + assert(JSON.stringify(packageShape(validation)) === JSON.stringify(packageShape(firstBuild)), 'validate/build differ in Package structure.'); + await verifyArtifactClosure(firstBuild); + await verifyCanonicalOutputs(); + await verifyManifestsAndDistributions(); + await verifyHooks(); + await verifyMcp(); + await verifyNodeRuntime(); + await verifyStableOutputSafety(firstBuild); + + /** 第一次构建后完整产物树的权限与字节快照。 */ + const firstSnapshot = await snapshotOutput(); + /** 第二次相同输入构建用于验证事务替换后的字节确定性。 */ + const secondBuild = await runCli('build'); + assert(JSON.stringify(firstBuild) === JSON.stringify(secondBuild), 'Repeated Playground build report is not byte-stable JSON.'); + /** 第二次构建后完整产物树的权限与字节快照。 */ + const secondSnapshot = await snapshotOutput(); + assert(JSON.stringify(firstSnapshot) === JSON.stringify(secondSnapshot), 'Repeated Playground build changed output bytes or modes.'); +} + +await main(); diff --git a/skills/add-converter/SKILL.md b/skills/add-converter/SKILL.md deleted file mode 100644 index fe30a9a..0000000 --- a/skills/add-converter/SKILL.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -name: add-converter -description: >- - Add a new resource type converter to acplugin (e.g., adding support for - converting a new Claude Code resource type) ---- - -# 添加新资源类型转换器 - -当需要支持转换新的 Claude Code 资源类型时,按以下步骤操作。 - -## 步骤 - -### 1. 定义类型 (`src/types.ts`) - -添加新资源的接口定义和 frontmatter 类型(如果有),以及在 `ScanResult` 中添加字段。在 `ConvertedFile.type` 联合类型中添加新值。 - -### 2. 添加扫描函数 (`src/scanner/claude.ts`) - -创建并导出可复用的扫描函数(如 `scanXxxDir()`),这样 `plugin.ts` 也能使用。 - -在 `scanClaudeProject()` 中调用新函数。 - -### 3. 集成 Plugin Scanner (`src/scanner/plugin.ts`) - -在 `scanPlugin()` 中调用新扫描函数,注意 plugin 目录结构与 .claude/ 不同: -- 项目: `.claude/xxx/` -- Plugin: `xxx/`(直接在 plugin 根目录下) - -更新 `countResources()` 包含新资源。 - -### 4. 创建 Converter (`src/converter/xxx.ts`) - -实现 `convertXxx(item, platform)` 函数,处理三个平台: - -```typescript -export function convertXxx(item: Xxx, platform: Platform): ConvertedFile { - switch (platform) { - case 'codex': return convertToCodex(item); - case 'opencode': return convertToOpenCode(item); - case 'cursor': return convertToCursor(item); - } -} -``` - -**关键原则**: -- Converter 无副作用,只返回 `ConvertedFile` -- 不支持的功能用降级策略(合并到 AGENTS.md 或 rules) -- 返回 warnings 告知用户不兼容项 - -### 5. 集成 Writer (`src/writer/*.ts`) - -在三个 writer 文件中调用新 converter,处理合并逻辑。 - -### 6. 更新 CLI 输出 (`src/index.ts`) - -更新 `printScanResult()` 和 `convertSingleScan()` 中的资源计数。 - -### 7. 添加测试 (`src/__tests__/xxx.test.ts`) - -为新 converter 创建测试,覆盖三个平台的转换逻辑。 - -### 8. 更新 test-fixture/ - -在 `test-fixture/` 中添加新资源类型的示例文件,确保 `scanner.test.ts` 覆盖。 - -## Frontmatter 解析容错 - -社区插件的 YAML 可能格式不规范。扫描函数中必须 try-catch `parseFrontmatter()`,解析失败时用空 frontmatter + 原始内容兜底。 diff --git a/skills/add-platform/SKILL.md b/skills/add-platform/SKILL.md deleted file mode 100644 index 3abaee1..0000000 --- a/skills/add-platform/SKILL.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -name: add-platform -description: 'Add support for a new target platform to acplugin (e.g., Windsurf, Zed, etc.)' ---- - -# 添加新目标平台 - -当需要支持新的 AI 编程工具作为转换目标时,按以下步骤操作。 - -## 前置调研 - -1. 了解目标平台的配置格式: - - Skills/技能文件格式和路径 - - 自定义指令文件(类似 CLAUDE.md / AGENTS.md) - - MCP 服务器配置格式 - - Agent 定义方式(如果有) - - 命令/斜杠命令格式 - - Hooks 系统(如果有) - -2. 确认格式差异和降级策略 - -## 实施步骤 - -### 1. 类型注册 (`src/types.ts`) - -在 `Platform` 联合类型中添加新值: -```typescript -export type Platform = 'codex' | 'opencode' | 'cursor' | 'newplatform'; -``` - -### 2. 每个 Converter 添加分支 - -在所有 `src/converter/*.ts` 文件中,给 `switch (platform)` 添加新的 case。 - -参考现有平台的转换逻辑,特别关注: -- **路径映射**:新平台的目录结构 -- **Frontmatter 差异**:新平台是否需要特殊字段 -- **降级策略**:不支持的功能如何处理 - -### 3. 创建 Writer (`src/writer/newplatform.ts`) - -复制 `cursor.ts` 作为模板,修改平台名: -```typescript -export function generateNewPlatform(scan: ScanResult): ConvertResult { ... } -``` - -### 4. CLI 注册 (`src/index.ts`) - -- `generateForPlatform()` 添加新 case -- `validPlatforms` 数组添加新值 -- import 新 writer - -### 5. TUI 注册 (`src/tui.ts`) - -在 `selectPlatforms()` 的 choices 中添加新选项。 - -### 6. 测试 - -- 每个 converter 测试文件添加新平台的用例 -- 新增 `src/__tests__/newplatform-writer.test.ts`(可选) - -### 7. 文档 - -- 更新 README.md 和 README.zh-CN.md 的支持矩阵表格 -- 更新 llmdoc/reference/conversion-matrix.md - -## 降级策略参考 - -| 场景 | 推荐策略 | -|------|---------| -| 平台无 Agent 系统 | 降级为指令/规则文件 | -| 平台无 Hooks | 记录为文档 + 输出 warning | -| 平台 MCP 格式不同 | 做字段映射转换 | -| 平台无 Skills 概念 | 转为命令或规则文件 | -| Claude 特有字段 | 保留为 HTML 注释 | diff --git a/skills/npm-publish/SKILL.md b/skills/npm-publish/SKILL.md deleted file mode 100644 index ba940e5..0000000 --- a/skills/npm-publish/SKILL.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -name: npm-publish -description: 'Publish acplugin to npm with version bump, build, test, and 2FA handling' -disable-model-invocation: true ---- - -# npm 发布流程 - -## 步骤 - -1. **版本升级** - ```bash - npm version --no-git-tag-version - ``` - -2. **构建 + 测试** - ```bash - npm run build && npm test - ``` - -3. **检查打包内容**(确认无测试文件) - ```bash - npm pack --dry-run - ``` - -4. **发布** - 账号有 2FA,需要用户手动输入 OTP: - ``` - 提示用户运行: ! npm publish --access=public - ``` - -5. **Commit + Push** - ```bash - git add package.json package-lock.json - git commit -m "chore: bump version to $(node -p 'require("./package.json").version')" - git push - ``` - -## 注意事项 - -- 包名是 `@disdjj/acplugin`(scoped),必须加 `--access=public` -- 不要尝试在脚本中自动发布,2FA 会阻塞 -- `prepublishOnly` 脚本会自动编译 -- `files` 字段已排除 `dist/__tests__/` diff --git a/src/__tests__/agent.test.ts b/src/__tests__/agent.test.ts deleted file mode 100644 index a189680..0000000 --- a/src/__tests__/agent.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { convertAgent } from '../converter/agent.js'; -import type { Agent } from '../types.js'; - -const sampleAgent: Agent = { - fileName: 'code-reviewer', - frontmatter: { - name: 'code-reviewer', - description: 'Reviews code for bugs', - tools: 'Read, Grep, Bash', - model: 'sonnet', - maxTurns: 20, - effort: 'high', - }, - body: '\nYou are a code reviewer.\n1. Check for bugs\n2. Report issues\n', - sourcePath: '/tmp/.claude/agents/code-reviewer.md', -}; - -describe('convertAgent', () => { - it('converts to codex as .toml subagent file', () => { - const result = convertAgent(sampleAgent, 'codex'); - expect(result.path).toBe('.codex/agents/code-reviewer.toml'); - expect(result.type).toBe('agent'); - expect(result.content).toContain('name = "code-reviewer"'); - expect(result.content).toContain('description = "Reviews code for bugs"'); - expect(result.content).toContain('developer_instructions'); - expect(result.content).toContain('You are a code reviewer'); - }); - - it('maps Claude model to gpt-5.6-sol for codex', () => { - const result = convertAgent(sampleAgent, 'codex'); - expect(result.content).toContain('model = "gpt-5.6-sol"'); - expect(result.content).not.toContain('sonnet'); - }); - - it('defaults to gpt-5.6-sol when no model specified', () => { - const noModelAgent: Agent = { - ...sampleAgent, - frontmatter: { ...sampleAgent.frontmatter, model: undefined }, - }; - const result = convertAgent(noModelAgent, 'codex'); - expect(result.content).toContain('model = "gpt-5.6-sol"'); - }); - - it('maps tools to sandbox_mode for codex', () => { - const result = convertAgent(sampleAgent, 'codex'); - // Has Bash in tools → workspace-write - expect(result.content).toContain('sandbox_mode = "workspace-write"'); - }); - - it('maps read-only tools to read-only sandbox for codex', () => { - const readOnlyAgent: Agent = { - ...sampleAgent, - frontmatter: { ...sampleAgent.frontmatter, tools: 'Read, Grep, Glob' }, - }; - const result = convertAgent(readOnlyAgent, 'codex'); - expect(result.content).toContain('sandbox_mode = "read-only"'); - }); - - it('maps effort to model_reasoning_effort for codex', () => { - const result = convertAgent(sampleAgent, 'codex'); - expect(result.content).toContain('model_reasoning_effort = "high"'); - }); - - it('converts to opencode as subagent file', () => { - const result = convertAgent(sampleAgent, 'opencode'); - expect(result.path).toBe('.opencode/agents/code-reviewer.md'); - expect(result.content).toContain('description: Reviews code for bugs'); - expect(result.content).toContain('mode: subagent'); - expect(result.content).toContain('steps: 20'); - expect(result.content).toContain('edit: deny'); - }); - - it('converts to cursor as agent file', () => { - const result = convertAgent(sampleAgent, 'cursor'); - expect(result.path).toBe('.cursor/agents/code-reviewer.md'); - expect(result.content).toContain('name: code-reviewer'); - expect(result.content).toContain('description: Reviews code for bugs'); - }); - - it('converts to antigravity as agent file', () => { - const result = convertAgent(sampleAgent, 'antigravity'); - expect(result.path).toBe('.agents/agents/code-reviewer.md'); - expect(result.content).toContain('name: code-reviewer'); - expect(result.content).toContain('model: gemini-3.1-pro-preview'); - // Tool identifiers are not published; original tools preserved as a comment. - expect(result.content).toContain('Read, Grep, Bash'); - expect(result.content).not.toContain('read_file'); - }); -}); diff --git a/src/__tests__/command.test.ts b/src/__tests__/command.test.ts deleted file mode 100644 index 40b3b76..0000000 --- a/src/__tests__/command.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { convertCommand } from '../converter/command.js'; -import type { Command } from '../types.js'; - -const sampleCommand: Command = { - name: 'deploy', - content: 'Deploy to $1 environment.\n\n1. Build\n2. Test\n3. Deploy', - sourcePath: '/tmp/.claude/commands/deploy.md', -}; - -describe('convertCommand', () => { - it('converts to codex as skill', () => { - const result = convertCommand(sampleCommand, 'codex'); - expect(result.path).toBe('.agents/skills/cmd-deploy/SKILL.md'); - expect(result.content).toContain('name: cmd-deploy'); - expect(result.content).toContain('Deploy to $1'); - }); - - it('converts to opencode as command file', () => { - const result = convertCommand(sampleCommand, 'opencode'); - expect(result.path).toBe('.opencode/commands/deploy.md'); - expect(result.content).toContain('Deploy to $1'); - }); - - it('converts to cursor as command file', () => { - const result = convertCommand(sampleCommand, 'cursor'); - expect(result.path).toBe('.cursor/commands/deploy.md'); - expect(result.content).toContain('Deploy to $1'); - }); - - it('converts to pi as a prompt template', () => { - const result = convertCommand(sampleCommand, 'pi'); - expect(result.path).toBe('.pi/prompts/deploy.md'); - expect(result.content).toContain('Deploy to $1'); - }); -}); diff --git a/src/__tests__/github.test.ts b/src/__tests__/github.test.ts deleted file mode 100644 index 2022afe..0000000 --- a/src/__tests__/github.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { parseGitHubSource } from '../github.js'; - -describe('parseGitHubSource', () => { - it('parses github:owner/repo', () => { - const result = parseGitHubSource('github:anthropics/claude-code'); - expect(result.owner).toBe('anthropics'); - expect(result.repo).toBe('claude-code'); - expect(result.branch).toBeUndefined(); - }); - - it('parses github:owner/repo#branch', () => { - const result = parseGitHubSource('github:anthropics/claude-code#main'); - expect(result.owner).toBe('anthropics'); - expect(result.repo).toBe('claude-code'); - expect(result.branch).toBe('main'); - }); - - it('parses owner/repo shorthand', () => { - const result = parseGitHubSource('anthropics/claude-code'); - expect(result.owner).toBe('anthropics'); - expect(result.repo).toBe('claude-code'); - }); - - it('parses owner/repo#branch shorthand', () => { - const result = parseGitHubSource('anthropics/claude-code#dev'); - expect(result.owner).toBe('anthropics'); - expect(result.repo).toBe('claude-code'); - expect(result.branch).toBe('dev'); - }); - - it('parses full GitHub URL', () => { - const result = parseGitHubSource('https://github.com/anthropics/claude-code'); - expect(result.owner).toBe('anthropics'); - expect(result.repo).toBe('claude-code'); - expect(result.branch).toBeUndefined(); - }); - - it('parses GitHub URL with branch', () => { - const result = parseGitHubSource('https://github.com/anthropics/claude-code/tree/main'); - expect(result.owner).toBe('anthropics'); - expect(result.repo).toBe('claude-code'); - expect(result.branch).toBe('main'); - }); - - it('parses GitHub URL with branch and subpath', () => { - const result = parseGitHubSource('https://github.com/anthropics/claude-code/tree/main/skills/my-skill'); - expect(result.owner).toBe('anthropics'); - expect(result.repo).toBe('claude-code'); - expect(result.branch).toBe('main'); - expect(result.subPath).toBe('skills/my-skill'); - }); - - it('strips .git suffix from URL', () => { - const result = parseGitHubSource('https://github.com/anthropics/claude-code.git'); - expect(result.repo).toBe('claude-code'); - }); - - it('throws on invalid source', () => { - expect(() => parseGitHubSource('invalid')).toThrow('Invalid GitHub source'); - }); -}); diff --git a/src/__tests__/hooks.test.ts b/src/__tests__/hooks.test.ts deleted file mode 100644 index 935c353..0000000 --- a/src/__tests__/hooks.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { convertHooks } from '../converter/hooks.js'; -import type { Hooks } from '../types.js'; - -const sampleHooks: Hooks = { - PostToolUse: [ - { - matcher: 'Edit|Write', - hooks: [{ type: 'command', command: 'npx prettier --write' }], - }, - ], - SessionStart: [ - { - hooks: [{ type: 'command', command: 'echo hello' }], - }, - ], - SubagentStart: [ - { - hooks: [{ type: 'prompt', command: 'check something' }], - }, - ], -}; - -describe('convertHooks', () => { - it('converts portable command hooks to codex notes', () => { - const result = convertHooks(sampleHooks, 'codex'); - expect(result.converted.length).toBeGreaterThan(0); - const postToolUse = result.converted.find(f => f.content.includes('PostToolUse')); - expect(postToolUse).toBeDefined(); - expect(postToolUse!.content).toContain('npx prettier --write'); - }); - - it('warns about non-portable events', () => { - const result = convertHooks(sampleHooks, 'codex'); - const subagentWarning = result.warnings.find(w => w.includes('SubagentStart')); - expect(subagentWarning).toBeDefined(); - }); - - it('warns about non-portable events with non-command hook types', () => { - const result = convertHooks(sampleHooks, 'codex'); - // SubagentStart is not portable, so it gets skipped with a warning about the event - const warning = result.warnings.find(w => w.includes('SubagentStart') && w.includes('not portable')); - expect(warning).toBeDefined(); - }); - - it('cannot convert hooks to cursor', () => { - const result = convertHooks(sampleHooks, 'cursor'); - // Cursor doesn't support file-based hooks, should warn - expect(result.warnings.length).toBeGreaterThan(0); - }); -}); diff --git a/src/__tests__/instructions.test.ts b/src/__tests__/instructions.test.ts deleted file mode 100644 index be07f2a..0000000 --- a/src/__tests__/instructions.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { mergeInstructions } from '../converter/instructions.js'; -import type { Instruction } from '../types.js'; - -const claudeMd: Instruction = { - fileName: 'CLAUDE.md', - content: '# Instructions\n\nAlways use TypeScript.', - sourcePath: '/tmp/CLAUDE.md', - isRule: false, -}; - -const rule: Instruction = { - fileName: 'testing.md', - content: '# Testing\n\nUse vitest.', - sourcePath: '/tmp/.claude/rules/testing.md', - isRule: true, -}; - -describe('mergeInstructions', () => { - it('merges into single AGENTS.md for codex', () => { - const result = mergeInstructions([claudeMd, rule], 'codex'); - expect(result).toHaveLength(1); - expect(result[0].path).toBe('AGENTS.md'); - expect(result[0].content).toContain('Always use TypeScript'); - expect(result[0].content).toContain('Rule: testing'); - expect(result[0].content).toContain('Use vitest'); - }); - - it('merges into single AGENTS.md for opencode', () => { - const result = mergeInstructions([claudeMd, rule], 'opencode'); - expect(result).toHaveLength(1); - expect(result[0].path).toBe('AGENTS.md'); - }); - - it('creates separate .mdc files for cursor', () => { - const result = mergeInstructions([claudeMd, rule], 'cursor'); - expect(result).toHaveLength(2); - expect(result[0].path).toBe('.cursor/rules/claude-instructions.mdc'); - expect(result[0].content).toContain('alwaysApply: true'); - expect(result[1].path).toBe('.cursor/rules/testing.mdc'); - expect(result[1].content).toContain('alwaysApply: true'); - }); - - it('returns empty array when no instructions', () => { - const result = mergeInstructions([], 'codex'); - expect(result).toHaveLength(0); - }); -}); diff --git a/src/__tests__/mcp.test.ts b/src/__tests__/mcp.test.ts deleted file mode 100644 index bd4c80d..0000000 --- a/src/__tests__/mcp.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { convertMCP } from '../converter/mcp.js'; -import type { MCPConfig } from '../types.js'; - -const sampleMCP: MCPConfig = { - servers: [ - { - name: 'filesystem', - command: 'npx', - args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp'], - env: { NODE_ENV: 'dev' }, - }, - { - name: 'github', - type: 'http', - url: 'https://api.github.com/mcp', - headers: { Authorization: 'Bearer token' }, - }, - ], - sourcePath: '/tmp/.mcp.json', -}; - -describe('convertMCP', () => { - it('converts to codex TOML format', () => { - const result = convertMCP(sampleMCP, 'codex'); - expect(result.path).toBe('.codex/config.toml'); - expect(result.content).toContain('[mcp_servers.filesystem]'); - expect(result.content).toContain('command = "npx"'); - expect(result.content).toContain('[mcp_servers.github]'); - expect(result.content).toContain('url = "https://api.github.com/mcp"'); - }); - - it('converts to opencode JSON format', () => { - const result = convertMCP(sampleMCP, 'opencode'); - expect(result.path).toBe('opencode.json'); - const data = JSON.parse(result.content); - expect(data.mcp.filesystem.type).toBe('local'); - // command is a single string array (command + args merged) - expect(data.mcp.filesystem.command).toEqual(['npx', '-y', '@modelcontextprotocol/server-filesystem', '/tmp']); - expect(data.mcp.filesystem.enabled).toBe(true); - // env vars use the `environment` key, not `env` - expect(data.mcp.filesystem.environment).toEqual({ NODE_ENV: 'dev' }); - expect(data.mcp.github.type).toBe('remote'); - expect(data.mcp.github.url).toBe('https://api.github.com/mcp'); - expect(data.mcp.github.enabled).toBe(true); - }); - - it('converts to cursor JSON format', () => { - const result = convertMCP(sampleMCP, 'cursor'); - expect(result.path).toBe('.cursor/mcp.json'); - const data = JSON.parse(result.content); - expect(data.mcpServers.filesystem.command).toBe('npx'); - expect(data.mcpServers.github.url).toBe('https://api.github.com/mcp'); - }); -}); diff --git a/src/__tests__/pi.test.ts b/src/__tests__/pi.test.ts deleted file mode 100644 index c4d883e..0000000 --- a/src/__tests__/pi.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { generatePi } from '../writer/pi.js'; -import type { ScanResult } from '../types.js'; - -function baseScan(overrides: Partial = {}): ScanResult { - return { - skills: [], - instructions: [], - mcp: null, - agents: [], - commands: [], - hooks: null, - pluginFiles: [], - rootDir: '/tmp/plugin', - ...overrides, - }; -} - -describe('generatePi', () => { - it('converts skills to .pi/skills/ and instructions to AGENTS.md', () => { - const scan = baseScan({ - skills: [{ - dirName: 'my-skill', - frontmatter: { name: 'my-skill', description: 'A skill' }, - body: '# My Skill', - sourcePath: '/tmp/plugin/skills/my-skill/SKILL.md', - auxFiles: [], - }], - instructions: [{ - fileName: 'CLAUDE.md', - content: '# Project rules', - sourcePath: '/tmp/plugin/CLAUDE.md', - isRule: false, - }], - }); - - const result = generatePi(scan); - expect(result.platform).toBe('pi'); - - const skillFile = result.files.find(f => f.type === 'skill'); - expect(skillFile?.path).toBe('.pi/skills/my-skill/SKILL.md'); - - const instrFile = result.files.find(f => f.type === 'instruction'); - expect(instrFile?.path).toBe('AGENTS.md'); - expect(instrFile?.content).toContain('# Project rules'); - - expect(result.warnings).toHaveLength(0); - }); - - it('degrades commands to prompt templates', () => { - const scan = baseScan({ - commands: [{ name: 'deploy', content: 'Deploy it', sourcePath: '/tmp/plugin/commands/deploy.md' }], - }); - - const result = generatePi(scan); - const cmdFile = result.files.find(f => f.type === 'command'); - expect(cmdFile?.path).toBe('.pi/prompts/deploy.md'); - expect(cmdFile?.content).toContain('Deploy it'); - }); - - it('warns and skips MCP, agents, and hooks (no Pi format)', () => { - const scan = baseScan({ - mcp: { servers: [{ name: 'fs', command: 'npx' }], sourcePath: '/tmp/plugin/.mcp.json' }, - agents: [{ fileName: 'reviewer', frontmatter: { name: 'reviewer' }, body: 'body', sourcePath: '/tmp/plugin/agents/reviewer.md' }], - hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 'echo hi' }] }] }, - }); - - const result = generatePi(scan); - - // None of these produce output files. - expect(result.files.filter(f => f.type === 'mcp')).toHaveLength(0); - expect(result.files.filter(f => f.type === 'agent')).toHaveLength(0); - expect(result.files.filter(f => f.type === 'hook')).toHaveLength(0); - - // Each unsupported type produces a warning. - expect(result.warnings.some(w => /MCP/.test(w))).toBe(true); - expect(result.warnings.some(w => /subagent/.test(w))).toBe(true); - expect(result.warnings.some(w => /hooks/.test(w))).toBe(true); - }); - - it('passes through plugin-level resource files', () => { - const scan = baseScan({ - pluginFiles: [{ relativePath: 'scripts/start.js', content: 'console.log(1)' }], - }); - - const result = generatePi(scan); - const resource = result.files.find(f => f.type === 'resource'); - expect(resource?.path).toBe('scripts/start.js'); - }); -}); diff --git a/src/__tests__/plugin.test.ts b/src/__tests__/plugin.test.ts deleted file mode 100644 index c142b5f..0000000 --- a/src/__tests__/plugin.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { describe, it, expect, beforeAll } from 'vitest'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { hasMarketplace, isSinglePlugin, scanMarketplace, scanPlugin, scanAllPlugins } from '../scanner/plugin.js'; -import { parseSelection } from '../tui.js'; - -// Create a temporary plugin fixture -let fixtureDir: string; - -beforeAll(() => { - fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'acplugin-test-')); - - // Create marketplace structure - fs.mkdirSync(path.join(fixtureDir, '.claude-plugin'), { recursive: true }); - fs.writeFileSync(path.join(fixtureDir, '.claude-plugin', 'marketplace.json'), JSON.stringify({ - name: 'test-plugins', - plugins: [ - { name: 'plugin-a', description: 'Plugin A', source: './plugins/plugin-a', category: 'dev' }, - { name: 'plugin-b', description: 'Plugin B', source: './plugins/plugin-b', category: 'prod' }, - { name: 'plugin-empty', description: 'Empty plugin', source: './plugins/plugin-empty' }, - ], - })); - - // Plugin A: has skills and commands - const pluginADir = path.join(fixtureDir, 'plugins', 'plugin-a'); - fs.mkdirSync(path.join(pluginADir, '.claude-plugin'), { recursive: true }); - fs.writeFileSync(path.join(pluginADir, '.claude-plugin', 'plugin.json'), JSON.stringify({ - name: 'plugin-a', version: '1.0.0', description: 'Plugin A', - })); - fs.mkdirSync(path.join(pluginADir, 'skills', 'my-skill'), { recursive: true }); - fs.writeFileSync(path.join(pluginADir, 'skills', 'my-skill', 'SKILL.md'), - '---\nname: my-skill\ndescription: Test skill\n---\n\nDo stuff.\n'); - fs.mkdirSync(path.join(pluginADir, 'commands'), { recursive: true }); - fs.writeFileSync(path.join(pluginADir, 'commands', 'deploy.md'), 'Deploy instructions'); - - // Plugin B: has agents and hooks - const pluginBDir = path.join(fixtureDir, 'plugins', 'plugin-b'); - fs.mkdirSync(path.join(pluginBDir, '.claude-plugin'), { recursive: true }); - fs.writeFileSync(path.join(pluginBDir, '.claude-plugin', 'plugin.json'), JSON.stringify({ - name: 'plugin-b', version: '2.0.0', - })); - fs.mkdirSync(path.join(pluginBDir, 'agents'), { recursive: true }); - fs.writeFileSync(path.join(pluginBDir, 'agents', 'reviewer.md'), - '---\nname: reviewer\ndescription: Code reviewer\n---\n\nReview code.\n'); - fs.mkdirSync(path.join(pluginBDir, 'hooks'), { recursive: true }); - fs.writeFileSync(path.join(pluginBDir, 'hooks', 'hooks.json'), JSON.stringify({ - hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 'echo check' }] }] }, - })); - - // Plugin Empty: no resources - const pluginEmptyDir = path.join(fixtureDir, 'plugins', 'plugin-empty'); - fs.mkdirSync(path.join(pluginEmptyDir, '.claude-plugin'), { recursive: true }); - fs.writeFileSync(path.join(pluginEmptyDir, '.claude-plugin', 'plugin.json'), JSON.stringify({ - name: 'plugin-empty', - })); -}); - -describe('plugin detection', () => { - it('detects marketplace', () => { - expect(hasMarketplace(fixtureDir)).toBe(true); - }); - - it('detects single plugin', () => { - const pluginADir = path.join(fixtureDir, 'plugins', 'plugin-a'); - expect(isSinglePlugin(pluginADir)).toBe(true); - }); - - it('returns false for non-plugin dir', () => { - expect(hasMarketplace('/tmp/nonexistent')).toBe(false); - expect(isSinglePlugin('/tmp/nonexistent')).toBe(false); - }); -}); - -describe('scanMarketplace', () => { - it('reads all plugin metadata', () => { - const metas = scanMarketplace(fixtureDir); - expect(metas).toHaveLength(3); - expect(metas[0].name).toBe('plugin-a'); - expect(metas[0].category).toBe('dev'); - expect(metas[1].name).toBe('plugin-b'); - }); -}); - -describe('scanPlugin', () => { - it('scans skills and commands from plugin-a', () => { - const pluginDir = path.join(fixtureDir, 'plugins', 'plugin-a'); - const result = scanPlugin(pluginDir); - expect(result.meta.name).toBe('plugin-a'); - expect(result.skills).toHaveLength(1); - expect(result.skills[0].frontmatter.name).toBe('my-skill'); - expect(result.commands).toHaveLength(1); - expect(result.commands[0].name).toBe('deploy'); - }); - - it('scans agents and hooks from plugin-b', () => { - const pluginDir = path.join(fixtureDir, 'plugins', 'plugin-b'); - const result = scanPlugin(pluginDir); - expect(result.meta.name).toBe('plugin-b'); - expect(result.agents).toHaveLength(1); - expect(result.hooks).not.toBeNull(); - expect(result.hooks!['PreToolUse']).toBeDefined(); - }); -}); - -describe('scanAllPlugins', () => { - it('scans all plugins and filters empty ones', () => { - const results = scanAllPlugins(fixtureDir); - // plugin-empty has no resources, should be filtered out - expect(results).toHaveLength(2); - expect(results[0].meta.name).toBe('plugin-a'); - expect(results[1].meta.name).toBe('plugin-b'); - }); -}); - -describe('parseSelection', () => { - it('parses "all"', () => { - expect(parseSelection('all', 5)).toEqual([0, 1, 2, 3, 4]); - }); - - it('parses "a"', () => { - expect(parseSelection('a', 3)).toEqual([0, 1, 2]); - }); - - it('parses "*"', () => { - expect(parseSelection('*', 3)).toEqual([0, 1, 2]); - }); - - it('parses comma-separated numbers', () => { - expect(parseSelection('1,3,5', 5)).toEqual([0, 2, 4]); - }); - - it('parses range', () => { - expect(parseSelection('2-4', 5)).toEqual([1, 2, 3]); - }); - - it('parses mixed', () => { - expect(parseSelection('1, 3-5', 6)).toEqual([0, 2, 3, 4]); - }); - - it('ignores out of range', () => { - expect(parseSelection('0, 10', 3)).toEqual([]); - }); -}); diff --git a/src/__tests__/pluginManifest.test.ts b/src/__tests__/pluginManifest.test.ts deleted file mode 100644 index 2fba80e..0000000 --- a/src/__tests__/pluginManifest.test.ts +++ /dev/null @@ -1,444 +0,0 @@ -import { describe, it, expect, beforeAll } from 'vitest'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { - convertPluginManifestForCodex, - convertPluginManifestForCursor, - convertMarketplaceForCodex, - convertMarketplaceForCursor, -} from '../converter/pluginManifest.js'; -import { scanPlugin, scanMarketplaceMeta, readPluginMeta, analyzeSourceTarget, scanAllPlugins } from '../scanner/plugin.js'; -import { convertMCP } from '../converter/mcp.js'; -import type { PluginMeta, ScanResult, PluginScanResult, MarketplaceMeta } from '../types.js'; - -// --- Fixtures --- - -let fixtureDir: string; -let pluginWithInterface: string; -let pluginWithCustomPaths: string; -let marketplaceWithPluginRoot: string; -let marketplaceWithSkillsSource: string; -let pluginWithMCP: string; - -beforeAll(() => { - fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'acplugin-manifest-test-')); - - // Plugin with full interface metadata - pluginWithInterface = path.join(fixtureDir, 'plugin-interface'); - fs.mkdirSync(path.join(pluginWithInterface, '.claude-plugin'), { recursive: true }); - fs.writeFileSync(path.join(pluginWithInterface, '.claude-plugin', 'plugin.json'), JSON.stringify({ - name: 'my-plugin', - version: '0.1.0', - description: 'A test plugin', - author: { name: 'Test Author', email: 'test@example.com', url: 'https://example.com' }, - homepage: 'https://example.com/plugin', - repository: 'https://github.com/test/plugin', - license: 'MIT', - keywords: ['test', 'plugin'], - skills: './skills/', - mcpServers: './.mcp.json', - apps: './.app.json', - interface: { - displayName: 'My Plugin', - shortDescription: 'Short desc', - longDescription: 'Long description here', - developerName: 'Test Team', - category: 'Productivity', - capabilities: ['Read', 'Write'], - websiteURL: 'https://example.com', - brandColor: '#10A37F', - logo: './assets/logo.png', - screenshots: ['./assets/screenshot.png'], - }, - })); - fs.mkdirSync(path.join(pluginWithInterface, 'skills', 'hello'), { recursive: true }); - fs.writeFileSync(path.join(pluginWithInterface, 'skills', 'hello', 'SKILL.md'), - '---\nname: hello\ndescription: Hello skill\n---\nHello!'); - - // Plugin with custom resource paths - pluginWithCustomPaths = path.join(fixtureDir, 'plugin-custom'); - fs.mkdirSync(path.join(pluginWithCustomPaths, '.claude-plugin'), { recursive: true }); - fs.writeFileSync(path.join(pluginWithCustomPaths, '.claude-plugin', 'plugin.json'), JSON.stringify({ - name: 'custom-paths', - version: '1.0.0', - skills: './custom/skills/', - agents: './custom/agents/', - hooks: './config/hooks.json', - })); - fs.mkdirSync(path.join(pluginWithCustomPaths, 'custom', 'skills', 'my-skill'), { recursive: true }); - fs.writeFileSync(path.join(pluginWithCustomPaths, 'custom', 'skills', 'my-skill', 'SKILL.md'), - '---\nname: custom-skill\ndescription: Custom path skill\n---\nCustom!'); - fs.mkdirSync(path.join(pluginWithCustomPaths, 'custom', 'agents'), { recursive: true }); - fs.writeFileSync(path.join(pluginWithCustomPaths, 'custom', 'agents', 'reviewer.md'), - '---\nname: reviewer\ndescription: Reviewer agent\n---\nReview code.'); - - // Marketplace with pluginRoot - marketplaceWithPluginRoot = path.join(fixtureDir, 'marketplace-root'); - fs.mkdirSync(path.join(marketplaceWithPluginRoot, '.claude-plugin'), { recursive: true }); - fs.writeFileSync(path.join(marketplaceWithPluginRoot, '.claude-plugin', 'marketplace.json'), JSON.stringify({ - name: 'my-org-marketplace', - owner: { name: 'Test Org', email: 'org@test.com' }, - metadata: { - description: 'Organization marketplace', - version: '0.1.0', - pluginRoot: 'plugins', - }, - plugins: [ - { name: 'devtools', source: 'devtools', description: 'Dev tools plugin' }, - { name: 'empty-plugin', source: 'empty-plugin', description: 'No resources' }, - ], - })); - - // devtools plugin under plugins/ directory - const devtoolsDir = path.join(marketplaceWithPluginRoot, 'plugins', 'devtools'); - fs.mkdirSync(path.join(devtoolsDir, '.claude-plugin'), { recursive: true }); - fs.writeFileSync(path.join(devtoolsDir, '.claude-plugin', 'plugin.json'), JSON.stringify({ - name: 'devtools', version: '1.0.0', description: 'Developer tools', - })); - fs.mkdirSync(path.join(devtoolsDir, 'skills', 'lint'), { recursive: true }); - fs.writeFileSync(path.join(devtoolsDir, 'skills', 'lint', 'SKILL.md'), - '---\nname: lint\ndescription: Lint code\n---\nLint!'); - - // empty plugin (no resources) - const emptyDir = path.join(marketplaceWithPluginRoot, 'plugins', 'empty-plugin'); - fs.mkdirSync(path.join(emptyDir, '.claude-plugin'), { recursive: true }); - fs.writeFileSync(path.join(emptyDir, '.claude-plugin', 'plugin.json'), JSON.stringify({ - name: 'empty-plugin', - })); - - // Marketplace where source points directly to skills dir (flat layout like chrome-devtools-capturer-repo) - marketplaceWithSkillsSource = path.join(fixtureDir, 'marketplace-flat'); - fs.mkdirSync(path.join(marketplaceWithSkillsSource, '.claude-plugin'), { recursive: true }); - fs.writeFileSync(path.join(marketplaceWithSkillsSource, '.claude-plugin', 'marketplace.json'), JSON.stringify({ - name: 'flat-plugin', - owner: { name: 'TestOwner' }, - plugins: [ - { name: 'my-capturer', version: '1.0.0', source: './skills', description: 'Flat skills plugin' }, - ], - })); - fs.mkdirSync(path.join(marketplaceWithSkillsSource, 'skills', 'capture'), { recursive: true }); - fs.writeFileSync(path.join(marketplaceWithSkillsSource, 'skills', 'capture', 'SKILL.md'), - '---\nname: capture\ndescription: Capture data\n---\nCapture!'); - fs.mkdirSync(path.join(marketplaceWithSkillsSource, 'skills', 'analyze'), { recursive: true }); - fs.writeFileSync(path.join(marketplaceWithSkillsSource, 'skills', 'analyze', 'SKILL.md'), - '---\nname: analyze\ndescription: Analyze data\n---\nAnalyze!'); - - // Plugin with .mcp.json referencing ${CLAUDE_PLUGIN_ROOT}/scripts/ - pluginWithMCP = path.join(fixtureDir, 'plugin-mcp'); - fs.mkdirSync(path.join(pluginWithMCP, '.claude-plugin'), { recursive: true }); - fs.writeFileSync(path.join(pluginWithMCP, '.claude-plugin', 'plugin.json'), JSON.stringify({ - name: 'mcp-plugin', - version: '1.0.0', - })); - fs.writeFileSync(path.join(pluginWithMCP, '.mcp.json'), JSON.stringify({ - mcpServers: { - 'my-server': { - command: 'node', - args: ['${CLAUDE_PLUGIN_ROOT}/scripts/server/start.js'], - env: { CONFIG: '${CLAUDE_PLUGIN_ROOT}/config/settings.json' }, - }, - }, - })); - fs.mkdirSync(path.join(pluginWithMCP, 'scripts', 'server'), { recursive: true }); - fs.writeFileSync(path.join(pluginWithMCP, 'scripts', 'server', 'start.js'), 'console.log("hello");'); - fs.mkdirSync(path.join(pluginWithMCP, 'skills', 'test-skill'), { recursive: true }); - fs.writeFileSync(path.join(pluginWithMCP, 'skills', 'test-skill', 'SKILL.md'), - '---\nname: test-skill\ndescription: Test\n---\nTest!'); -}); - -// --- Tests --- - -describe('readPluginMeta - resource paths', () => { - it('extracts resource path fields from plugin.json', () => { - const meta = readPluginMeta(pluginWithInterface); - expect(meta.skills).toBe('./skills/'); - expect(meta.mcpServers).toBe('./.mcp.json'); - expect(meta.apps).toBe('./.app.json'); - }); - - it('extracts interface metadata', () => { - const meta = readPluginMeta(pluginWithInterface); - expect(meta.interface).toBeDefined(); - expect(meta.interface!.displayName).toBe('My Plugin'); - expect(meta.interface!.category).toBe('Productivity'); - expect(meta.interface!.brandColor).toBe('#10A37F'); - expect(meta.interface!.capabilities).toEqual(['Read', 'Write']); - }); - - it('extracts custom paths', () => { - const meta = readPluginMeta(pluginWithCustomPaths); - expect(meta.skills).toBe('./custom/skills/'); - expect(meta.agents).toBe('./custom/agents/'); - expect(meta.hooks).toBe('./config/hooks.json'); - }); -}); - -describe('scanPlugin - custom paths', () => { - it('scans skills from custom directory', () => { - const result = scanPlugin(pluginWithCustomPaths); - expect(result.skills).toHaveLength(1); - expect(result.skills[0].frontmatter.name).toBe('custom-skill'); - }); - - it('scans agents from custom directory', () => { - const result = scanPlugin(pluginWithCustomPaths); - expect(result.agents).toHaveLength(1); - expect(result.agents[0].frontmatter.name).toBe('reviewer'); - }); -}); - -describe('scanMarketplaceMeta - pluginRoot', () => { - it('returns full marketplace metadata with pluginRoot', () => { - const meta = scanMarketplaceMeta(marketplaceWithPluginRoot); - expect(meta).not.toBeNull(); - expect(meta!.name).toBe('my-org-marketplace'); - expect(meta!.metadata?.pluginRoot).toBe('plugins'); - expect(meta!.owner?.name).toBe('Test Org'); - expect(meta!.plugins).toHaveLength(2); - }); -}); - -describe('convertPluginManifestForCodex', () => { - it('generates .codex-plugin/plugin.json with full metadata', () => { - const scan = scanPlugin(pluginWithInterface); - const result = convertPluginManifestForCodex(scan, scan.meta); - - expect(result.path).toBe('.codex-plugin/plugin.json'); - expect(result.type).toBe('manifest'); - - const manifest = JSON.parse(result.content); - expect(manifest.name).toBe('my-plugin'); - expect(manifest.version).toBe('0.1.0'); - expect(manifest.author.name).toBe('Test Author'); - expect(manifest.skills).toBe('./.agents/skills/'); - expect(manifest.interface.displayName).toBe('My Plugin'); - expect(manifest.interface.category).toBe('Productivity'); - expect(manifest.interface.brandColor).toBe('#10A37F'); - }); - - it('includes apps path when present in meta', () => { - const scan = scanPlugin(pluginWithInterface); - const result = convertPluginManifestForCodex(scan, scan.meta); - const manifest = JSON.parse(result.content); - expect(manifest.apps).toBe('./.app.json'); - }); -}); - -describe('convertPluginManifestForCursor', () => { - it('generates .cursor-plugin/plugin.json with interface fields', () => { - const scan = scanPlugin(pluginWithInterface); - const result = convertPluginManifestForCursor(scan, scan.meta); - - expect(result.path).toBe('.cursor-plugin/plugin.json'); - - const manifest = JSON.parse(result.content); - expect(manifest.name).toBe('my-plugin'); - expect(manifest.displayName).toBe('My Plugin'); - expect(manifest.logo).toBe('./assets/logo.png'); - expect(manifest.skills).toBe('./skills/'); - }); - - it('falls back to interface.displayName when no top-level displayName', () => { - const scan: ScanResult = { - skills: [], instructions: [], mcp: null, agents: [], commands: [], hooks: null, pluginFiles: [], - rootDir: '/tmp', - }; - const meta: PluginMeta = { - name: 'test', - interface: { displayName: 'From Interface' }, - }; - const result = convertPluginManifestForCursor(scan, meta); - const manifest = JSON.parse(result.content); - expect(manifest.displayName).toBe('From Interface'); - }); -}); - -describe('convertMarketplaceForCodex', () => { - it('generates Codex marketplace.json with policy defaults', () => { - const marketplace: MarketplaceMeta = { - name: 'test-marketplace', - metadata: { description: 'Test marketplace' }, - plugins: [ - { name: 'plugin-a', source: './plugins/plugin-a', description: 'Plugin A' }, - ], - }; - const pluginScans: PluginScanResult[] = [{ - meta: { name: 'plugin-a', description: 'Plugin A', category: 'Productivity' }, - skills: [{ dirName: 'skill', frontmatter: { name: 'skill' }, body: '', sourcePath: '', auxFiles: [] }], - instructions: [], mcp: null, agents: [], commands: [], hooks: null, pluginFiles: [], rootDir: '/tmp', - }]; - - const result = convertMarketplaceForCodex(marketplace, pluginScans); - expect(result.path).toBe('.agents/plugins/marketplace.json'); - - const output = JSON.parse(result.content); - expect(output.name).toBe('test-marketplace'); - expect(output.plugins).toHaveLength(1); - expect(output.plugins[0].source).toEqual({ source: 'local', path: './plugins/plugin-a' }); - expect(output.plugins[0].policy).toEqual({ - installation: 'AVAILABLE', - authentication: 'ON_INSTALL', - }); - expect(output.plugins[0].category).toBe('Productivity'); - }); -}); - -describe('convertMarketplaceForCursor', () => { - it('generates Cursor marketplace.json with pluginRoot', () => { - const marketplace: MarketplaceMeta = { - name: 'my-org', - owner: { name: 'Test Org', email: 'org@test.com' }, - metadata: { description: 'Org marketplace', version: '0.1.0', pluginRoot: 'plugins' }, - plugins: [ - { name: 'devtools', source: 'devtools', description: 'Dev tools' }, - ], - }; - const pluginScans: PluginScanResult[] = [{ - meta: { name: 'devtools', description: 'Dev tools' }, - skills: [{ dirName: 'skill', frontmatter: { name: 'skill' }, body: '', sourcePath: '', auxFiles: [] }], - instructions: [], mcp: null, agents: [], commands: [], hooks: null, pluginFiles: [], rootDir: '/tmp', - }]; - - const result = convertMarketplaceForCursor(marketplace, pluginScans); - expect(result.path).toBe('.cursor-plugin/marketplace.json'); - - const output = JSON.parse(result.content); - expect(output.name).toBe('my-org'); - expect(output.owner.name).toBe('Test Org'); - expect(output.metadata.pluginRoot).toBe('plugins'); - expect(output.plugins[0].source).toBe('devtools'); - }); -}); - -// --- Source target analysis --- - -describe('analyzeSourceTarget', () => { - it('detects plugin root by .claude-plugin/plugin.json', () => { - expect(analyzeSourceTarget(pluginWithInterface)).toBe('plugin-root'); - }); - - it('detects plugin root by skills/ subdirectory', () => { - // marketplaceWithPluginRoot/plugins/devtools has skills/ subdir - const devtoolsDir = path.join(marketplaceWithPluginRoot, 'plugins', 'devtools'); - expect(analyzeSourceTarget(devtoolsDir)).toBe('plugin-root'); - }); - - it('detects skills directory by name', () => { - const skillsDir = path.join(marketplaceWithSkillsSource, 'skills'); - expect(analyzeSourceTarget(skillsDir)).toBe('skills-dir'); - }); - - it('returns unknown for empty directory', () => { - const emptyDir = path.join(fixtureDir, 'empty-dir'); - fs.mkdirSync(emptyDir, { recursive: true }); - expect(analyzeSourceTarget(emptyDir)).toBe('unknown'); - }); -}); - -describe('scanAllPlugins - flat layout (source: "./skills")', () => { - it('scans skills when source points directly to skills dir', () => { - const results = scanAllPlugins(marketplaceWithSkillsSource); - expect(results).toHaveLength(1); - expect(results[0].meta.name).toBe('my-capturer'); - expect(results[0].skills).toHaveLength(2); - expect(results[0].skills.map(s => s.frontmatter.name).sort()).toEqual(['analyze', 'capture']); - }); - - it('preserves metadata from marketplace entry', () => { - const results = scanAllPlugins(marketplaceWithSkillsSource); - expect(results[0].meta.description).toBe('Flat skills plugin'); - expect(results[0].meta.version).toBe('1.0.0'); - }); -}); - -// --- MCP scanning and plugin-level files --- - -describe('scanPlugin - MCP support', () => { - it('scans .mcp.json from plugin directory', () => { - const result = scanPlugin(pluginWithMCP); - expect(result.mcp).not.toBeNull(); - expect(result.mcp!.servers).toHaveLength(1); - expect(result.mcp!.servers[0].name).toBe('my-server'); - expect(result.mcp!.servers[0].command).toBe('node'); - expect(result.mcp!.servers[0].args![0]).toContain('${CLAUDE_PLUGIN_ROOT}'); - }); - - it('scans plugin-level resource files referenced by MCP', () => { - const result = scanPlugin(pluginWithMCP); - expect(result.pluginFiles.length).toBeGreaterThan(0); - const scriptFile = result.pluginFiles.find(f => f.relativePath.includes('start.js')); - expect(scriptFile).toBeDefined(); - expect(scriptFile!.content).toBe('console.log("hello");'); - }); - - it('still scans skills alongside MCP', () => { - const result = scanPlugin(pluginWithMCP); - expect(result.skills).toHaveLength(1); - expect(result.skills[0].frontmatter.name).toBe('test-skill'); - }); -}); - -describe('MCP converter - ${CLAUDE_PLUGIN_ROOT} transformation', () => { - it('transforms args for Codex', () => { - const mcp = { - servers: [{ - name: 'test', - command: 'node', - args: ['${CLAUDE_PLUGIN_ROOT}/scripts/server/start.js'], - }], - sourcePath: '/tmp/.mcp.json', - }; - const result = convertMCP(mcp, 'codex'); - expect(result.content).not.toContain('CLAUDE_PLUGIN_ROOT'); - expect(result.content).toContain('./scripts/server/start.js'); - }); - - it('transforms env values for Cursor', () => { - const mcp = { - servers: [{ - name: 'test', - command: 'node', - args: ['${CLAUDE_PLUGIN_ROOT}/scripts/start.js'], - env: { CONFIG: '${CLAUDE_PLUGIN_ROOT}/config/settings.json' }, - }], - sourcePath: '/tmp/.mcp.json', - }; - const result = convertMCP(mcp, 'cursor'); - const parsed = JSON.parse(result.content); - const server = parsed.mcpServers.test; - expect(server.args[0]).toBe('./scripts/start.js'); - expect(server.env.CONFIG).toBe('./config/settings.json'); - }); - - it('transforms for OpenCode', () => { - const mcp = { - servers: [{ - name: 'test', - command: 'node', - args: ['${CLAUDE_PLUGIN_ROOT}/scripts/start.js'], - }], - sourcePath: '/tmp/.mcp.json', - }; - const result = convertMCP(mcp, 'opencode'); - const parsed = JSON.parse(result.content); - // OpenCode merges command+args into a single string array. - expect(parsed.mcp.test.command).toEqual(['node', './scripts/start.js']); - }); - - it('handles bare ${CLAUDE_PLUGIN_ROOT} without trailing path', () => { - const mcp = { - servers: [{ - name: 'test', - command: 'node', - args: ['${CLAUDE_PLUGIN_ROOT}'], - env: { ROOT: '${CLAUDE_PLUGIN_ROOT}' }, - }], - sourcePath: '/tmp/.mcp.json', - }; - const result = convertMCP(mcp, 'cursor'); - const parsed = JSON.parse(result.content); - expect(parsed.mcpServers.test.args[0]).toBe('.'); - expect(parsed.mcpServers.test.env.ROOT).toBe('.'); - }); -}); diff --git a/src/__tests__/scanner.test.ts b/src/__tests__/scanner.test.ts deleted file mode 100644 index a83632c..0000000 --- a/src/__tests__/scanner.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import * as path from 'path'; -import { scanClaudeProject } from '../scanner/claude.js'; - -const fixtureDir = path.resolve(__dirname, '../../test-fixture'); - -describe('scanClaudeProject', () => { - it('scans all resource types from test fixture', () => { - const result = scanClaudeProject(fixtureDir); - - expect(result.skills).toHaveLength(1); - expect(result.skills[0].dirName).toBe('my-skill'); - expect(result.skills[0].frontmatter.name).toBe('my-skill'); - - expect(result.instructions).toHaveLength(2); - expect(result.instructions.some(i => i.fileName === 'CLAUDE.md')).toBe(true); - expect(result.instructions.some(i => i.isRule)).toBe(true); - - expect(result.mcp).not.toBeNull(); - expect(result.mcp!.servers).toHaveLength(2); - - expect(result.agents).toHaveLength(1); - expect(result.agents[0].fileName).toBe('code-reviewer'); - - expect(result.commands).toHaveLength(1); - expect(result.commands[0].name).toBe('deploy'); - - expect(result.hooks).not.toBeNull(); - expect(Object.keys(result.hooks!)).toHaveLength(2); - }); - - it('returns empty results for non-existent directory', () => { - const result = scanClaudeProject('/tmp/nonexistent-dir-xyz'); - expect(result.skills).toHaveLength(0); - expect(result.instructions).toHaveLength(0); - expect(result.mcp).toBeNull(); - expect(result.agents).toHaveLength(0); - expect(result.commands).toHaveLength(0); - expect(result.hooks).toBeNull(); - }); -}); diff --git a/src/__tests__/skill.test.ts b/src/__tests__/skill.test.ts deleted file mode 100644 index 4a810e3..0000000 --- a/src/__tests__/skill.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { convertSkill, convertSkillCodexYaml } from '../converter/skill.js'; -import type { Skill } from '../types.js'; - -const sampleSkill: Skill = { - dirName: 'test-skill', - frontmatter: { - name: 'test-skill', - description: 'A test skill', - 'disable-model-invocation': true, - 'allowed-tools': 'Read, Grep', - model: 'sonnet', - effort: 'high', - context: 'fork', - agent: 'Explore', - }, - body: '\n# Test Skill\n\nDo something useful.\n', - sourcePath: '/tmp/.claude/skills/test-skill/SKILL.md', - auxFiles: [], -}; - -describe('convertSkill', () => { - it('converts to codex path', () => { - const result = convertSkill(sampleSkill, 'codex'); - expect(result.path).toBe('.agents/skills/test-skill/SKILL.md'); - expect(result.type).toBe('skill'); - }); - - it('converts to opencode path', () => { - const result = convertSkill(sampleSkill, 'opencode'); - expect(result.path).toBe('.opencode/skills/test-skill/SKILL.md'); - }); - - it('converts to cursor path', () => { - const result = convertSkill(sampleSkill, 'cursor'); - expect(result.path).toBe('.cursor/skills/test-skill/SKILL.md'); - }); - - it('converts to pi path', () => { - const result = convertSkill(sampleSkill, 'pi'); - expect(result.path).toBe('.pi/skills/test-skill/SKILL.md'); - // Pi supports Claude-style allowed-tools + disable-model-invocation natively. - expect(result.content).toContain('allowed-tools:'); - expect(result.content).toContain('Read, Grep'); - expect(result.content).toContain('disable-model-invocation: true'); - }); - - it('preserves name and description in frontmatter', () => { - const result = convertSkill(sampleSkill, 'codex'); - expect(result.content).toContain('name: test-skill'); - expect(result.content).toContain('description: A test skill'); - }); - - it('removes Claude-specific fields from frontmatter', () => { - const result = convertSkill(sampleSkill, 'codex'); - expect(result.content).not.toMatch(/^context:/m); - expect(result.content).not.toMatch(/^agent:/m); - expect(result.content).not.toMatch(/^effort:/m); - expect(result.content).not.toMatch(/^model:/m); - }); - - it('adds Claude-specific fields as HTML comment', () => { - const result = convertSkill(sampleSkill, 'codex'); - expect(result.content).toContain('\n`; - } - - const content = stringifyFrontmatter(fm, body); - return { path: `.agents/agents/${agent.fileName}.md`, content, type: 'agent' }; -} diff --git a/src/converter/command.ts b/src/converter/command.ts deleted file mode 100644 index 38cafc4..0000000 --- a/src/converter/command.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { Command, Platform, ConvertedFile } from '../types.js'; -import { stringifyFrontmatter } from '../utils/frontmatter.js'; - -export function convertCommand(command: Command, platform: Platform): ConvertedFile { - switch (platform) { - case 'codex': - return convertToCodex(command); - case 'opencode': - return convertToOpenCode(command); - case 'cursor': - return convertToCursor(command); - case 'antigravity': - return convertToAntigravity(command); - case 'pi': - return convertToPi(command); - } -} - -function convertToPi(command: Command): ConvertedFile { - // Pi has no dedicated command format; the closest native mechanism is a - // prompt template (.md under .pi/prompts/), exposed as a /name slash command. - return { - path: `.pi/prompts/${command.name}.md`, - content: command.content, - type: 'command', - }; -} - -function convertToCodex(command: Command): ConvertedFile { - // Codex exposes commands as skills - const fm = { - name: `cmd-${command.name}`, - description: `Command: ${command.name} (imported from Claude Code)`, - }; - const content = stringifyFrontmatter(fm, command.content); - - return { - path: `.agents/skills/cmd-${command.name}/SKILL.md`, - content, - type: 'command', - }; -} - -function convertToOpenCode(command: Command): ConvertedFile { - // OpenCode uses .opencode/commands/*.md (same format) - return { - path: `.opencode/commands/${command.name}.md`, - content: command.content, - type: 'command', - }; -} - -function convertToCursor(command: Command): ConvertedFile { - return { - path: `.cursor/commands/${command.name}.md`, - content: command.content, - type: 'command', - }; -} - -function convertToAntigravity(command: Command): ConvertedFile { - // Antigravity: convert commands to skills (no separate commands dir) - const fm = { - name: command.name, - description: `Command: ${command.name} (imported from Claude Code)`, - }; - const content = stringifyFrontmatter(fm, command.content); - return { - path: `.agents/skills/cmd-${command.name}/SKILL.md`, - content, - type: 'command', - }; -} diff --git a/src/converter/hooks.ts b/src/converter/hooks.ts deleted file mode 100644 index 030ff4e..0000000 --- a/src/converter/hooks.ts +++ /dev/null @@ -1,133 +0,0 @@ -import type { Hooks, Platform, ConvertedFile } from '../types.js'; - -// Events that have reasonable mapping across platforms -const PORTABLE_EVENTS = ['PostToolUse', 'PreToolUse', 'Stop', 'SessionStart']; - -// Claude Code PascalCase → Cursor camelCase event name mapping -const CURSOR_EVENT_MAP: Record = { - 'PostToolUse': 'postToolUse', - 'PreToolUse': 'preToolUse', - 'Stop': 'stop', - 'SessionStart': 'sessionStart', -}; - -interface HookReport { - converted: ConvertedFile[]; - warnings: string[]; -} - -export function convertHooks(hooks: Hooks, platform: Platform): HookReport { - if (platform === 'cursor') { - return convertCursorHooks(hooks); - } - - const warnings: string[] = []; - const converted: ConvertedFile[] = []; - - for (const [event, matchers] of Object.entries(hooks)) { - if (!PORTABLE_EVENTS.includes(event)) { - warnings.push(`Hook event "${event}" is not portable to ${platform} — skipped`); - continue; - } - - for (const matcher of matchers) { - for (const hook of matcher.hooks) { - if (hook.type === 'command' && hook.command) { - const result = convertCommandHook(event, matcher.matcher, hook.command, platform); - if (result) { - converted.push(result); - } else { - warnings.push(`Hook ${event}/${matcher.matcher || '*'} cannot be directly converted to ${platform}`); - } - } else if (hook.type === 'prompt' || hook.type === 'agent') { - warnings.push(`Hook type "${hook.type}" for event "${event}" is Claude Code specific — cannot convert to ${platform}`); - } else if (hook.type === 'http') { - warnings.push(`HTTP hook for event "${event}" — manual configuration needed for ${platform}`); - } - } - } - } - - return { converted, warnings }; -} - -/** - * Convert Claude Code hooks to Cursor hooks format. - * Cursor hooks use camelCase event names, no matcher, and a version field. - */ -function convertCursorHooks(hooks: Hooks): HookReport { - const warnings: string[] = []; - const cursorHooks: Record> = {}; - - for (const [event, matchers] of Object.entries(hooks)) { - const cursorEvent = CURSOR_EVENT_MAP[event]; - if (!cursorEvent) { - warnings.push(`Hook event "${event}" is not supported in Cursor — skipped`); - continue; - } - - const entries: Array<{ command: string }> = []; - for (const matcher of matchers) { - for (const hook of matcher.hooks) { - if (hook.type === 'command' && hook.command) { - // Strip ${CLAUDE_PLUGIN_ROOT}/ prefix and adapt path for Cursor - let cmd = hook.command; - cmd = cmd.replace(/"\$\{CLAUDE_PLUGIN_ROOT\}\/([^"]+)"/g, './$1'); - cmd = cmd.replace(/\$\{CLAUDE_PLUGIN_ROOT\}\//g, './'); - entries.push({ command: cmd }); - } else { - warnings.push(`Hook type "${hook.type}" for event "${event}" is not supported in Cursor — skipped`); - } - } - } - - if (entries.length > 0) { - cursorHooks[cursorEvent] = entries; - } - } - - const converted: ConvertedFile[] = []; - if (Object.keys(cursorHooks).length > 0) { - const content = JSON.stringify({ version: 1, hooks: cursorHooks }, null, 2); - converted.push({ - path: 'hooks/hooks-cursor.json', - content, - type: 'hook', - }); - } - - return { converted, warnings }; -} - -function convertCommandHook( - event: string, - matcher: string | undefined, - command: string, - platform: Platform -): ConvertedFile | null { - switch (platform) { - case 'cursor': - // Cursor doesn't have hooks yet in a config file format we can write - return null; - case 'codex': - // Codex doesn't have hooks — add as a note in AGENTS.md - return { - path: `AGENTS.md.hook-${event}`, - content: `## Hook: ${event}${matcher ? ` (${matcher})` : ''}\n\nRun after ${event}: \`${command}\`\n`, - type: 'hook', - }; - case 'opencode': - // OpenCode doesn't have a public hooks system — add as a note - return { - path: `AGENTS.md.hook-${event}`, - content: `## Hook: ${event}${matcher ? ` (${matcher})` : ''}\n\nRun after ${event}: \`${command}\`\n`, - type: 'hook', - }; - case 'antigravity': - // Antigravity doesn't have file-configurable hooks - return null; - case 'pi': - // Pi handles hooks only via TypeScript extensions — no file format. - return null; - } -} diff --git a/src/converter/instructions.ts b/src/converter/instructions.ts deleted file mode 100644 index b75f341..0000000 --- a/src/converter/instructions.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { Instruction, Platform, ConvertedFile } from '../types.js'; -import { stringifyFrontmatter } from '../utils/frontmatter.js'; - -export function convertInstruction(instruction: Instruction, platform: Platform): ConvertedFile { - switch (platform) { - case 'codex': - return convertToCodex(instruction); - case 'opencode': - return convertToOpenCode(instruction); - case 'cursor': - return convertToCursor(instruction); - case 'antigravity': - return convertToAntigravity(instruction); - case 'pi': - return convertToPi(instruction); - } -} - -function convertToPi(instruction: Instruction): ConvertedFile { - // Pi loads AGENTS.md (or CLAUDE.md), concatenating all matches up the tree. - return { - path: 'AGENTS.md', - content: instruction.content, - type: 'instruction', - }; -} - -function convertToCodex(instruction: Instruction): ConvertedFile { - // CLAUDE.md and rules both map to AGENTS.md (content is directly usable). - return { - path: 'AGENTS.md', - content: instruction.content, - type: 'instruction', - }; -} - -function convertToOpenCode(instruction: Instruction): ConvertedFile { - // CLAUDE.md → AGENTS.md (OpenCode is compatible) - return { - path: 'AGENTS.md', - content: instruction.content, - type: 'instruction', - }; -} - -function convertToCursor(instruction: Instruction): ConvertedFile { - if (instruction.isRule) { - // .claude/rules/X.md → .cursor/rules/X.mdc with frontmatter - const name = instruction.fileName.replace(/\.md$/, ''); - const frontmatter = { - description: `Imported from Claude Code rule: ${name}`, - alwaysApply: true, - }; - return { - path: `.cursor/rules/${name}.mdc`, - content: stringifyFrontmatter(frontmatter, instruction.content), - type: 'instruction', - }; - } - - // CLAUDE.md → .cursor/rules/claude-instructions.mdc - const frontmatter = { - description: 'Project instructions imported from Claude Code CLAUDE.md', - alwaysApply: true, - }; - return { - path: '.cursor/rules/claude-instructions.mdc', - content: stringifyFrontmatter(frontmatter, instruction.content), - type: 'instruction', - }; -} - -/** - * Merge multiple instructions into a single file for platforms that use one file. - * Codex and OpenCode both use a single AGENTS.md. - */ -function convertToAntigravity(instruction: Instruction): ConvertedFile { - // CLAUDE.md → GEMINI.md - return { - path: 'GEMINI.md', - content: instruction.content, - type: 'instruction', - }; -} - -export function mergeInstructions(instructions: Instruction[], platform: Platform): ConvertedFile[] { - if (platform === 'cursor') { - return instructions.map(i => convertInstruction(i, platform)); - } - - if (platform === 'antigravity') { - // Antigravity: merge into GEMINI.md - if (instructions.length === 0) return []; - const sections: string[] = []; - for (const inst of instructions) { - if (inst.isRule) { - const name = inst.fileName.replace(/\.md$/, ''); - sections.push(`\n## Rule: ${name}\n\n${inst.content}`); - } else { - sections.push(inst.content); - } - } - return [{ path: 'GEMINI.md', content: sections.join('\n\n---\n'), type: 'instruction' }]; - } - - // Codex / OpenCode: merge all into one AGENTS.md - if (instructions.length === 0) return []; - - const sections: string[] = []; - for (const inst of instructions) { - if (inst.isRule) { - const name = inst.fileName.replace(/\.md$/, ''); - sections.push(`\n## Rule: ${name}\n\n${inst.content}`); - } else { - sections.push(inst.content); - } - } - - return [{ - path: 'AGENTS.md', - content: sections.join('\n\n---\n'), - type: 'instruction', - }]; -} diff --git a/src/converter/mcp.ts b/src/converter/mcp.ts deleted file mode 100644 index fd81df6..0000000 --- a/src/converter/mcp.ts +++ /dev/null @@ -1,170 +0,0 @@ -import type { MCPConfig, Platform, ConvertedFile } from '../types.js'; -import { toToml } from '../utils/toml.js'; - -/** - * Replace ${CLAUDE_PLUGIN_ROOT} with relative path. - * All target platforms use relative paths from plugin root. - */ -function transformPluginRootPaths(value: string): string { - return value - .replace(/"\$\{CLAUDE_PLUGIN_ROOT\}\/([^"]+)"/g, './$1') - .replace(/\$\{CLAUDE_PLUGIN_ROOT\}\//g, './') - .replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, '.'); -} - -function transformArgs(args: string[]): string[] { - return args.map(a => transformPluginRootPaths(a)); -} - -function transformEnv(env: Record): Record { - const result: Record = {}; - for (const [k, v] of Object.entries(env)) { - result[k] = transformPluginRootPaths(v); - } - return result; -} - -export function convertMCP(mcp: MCPConfig, platform: Platform): ConvertedFile { - switch (platform) { - case 'codex': - return convertToCodex(mcp); - case 'opencode': - return convertToOpenCode(mcp); - case 'cursor': - return convertToCursor(mcp); - case 'antigravity': - return convertToAntigravity(mcp); - case 'pi': - // Pi does not support MCP by design; the Pi writer emits a warning - // instead of calling this converter. - throw new Error('Pi does not support MCP'); - } -} - -function convertToCodex(mcp: MCPConfig): ConvertedFile { - // Generate TOML [mcp_servers.X] sections - const mcpServers: Record> = {}; - - for (const server of mcp.servers) { - const config: Record = {}; - - if (server.type === 'http' && server.url) { - config.url = server.url; - if (server.headers) { - config.http_headers = server.headers; - } - } else { - if (server.command) config.command = server.command; - if (server.args) config.args = transformArgs(server.args); - } - - if (server.env && Object.keys(server.env).length > 0) { - config.env = transformEnv(server.env); - } - - config.enabled = true; - mcpServers[server.name] = config; - } - - const content = toToml({ mcp_servers: mcpServers }); - return { - path: '.codex/config.toml', - content: `# MCP servers converted from Claude Code .mcp.json\n\n${content}`, - type: 'mcp', - }; -} - -function convertToOpenCode(mcp: MCPConfig): ConvertedFile { - const mcpConfig: Record = {}; - - for (const server of mcp.servers) { - if (server.type === 'http' && server.url) { - mcpConfig[server.name] = { - type: 'remote', - url: server.url, - enabled: true, - ...(server.headers ? { headers: server.headers } : {}), - }; - } else { - // OpenCode expects a single string array for command+args, and uses - // `environment` (not `env`) for env vars. See opencode.ai/docs/mcp-servers. - const commandArray = [ - ...(server.command ? [server.command] : []), - ...transformArgs(server.args || []), - ]; - mcpConfig[server.name] = { - type: 'local', - command: commandArray, - enabled: true, - ...(server.env && Object.keys(server.env).length > 0 ? { environment: transformEnv(server.env) } : {}), - }; - } - } - - const content = JSON.stringify({ mcp: mcpConfig }, null, 2); - return { - path: 'opencode.json', - content, - type: 'mcp', - }; -} - -function convertToCursor(mcp: MCPConfig): ConvertedFile { - // Cursor format is almost identical to Claude's .mcp.json - const mcpServers: Record = {}; - - for (const server of mcp.servers) { - const config: Record = {}; - - if (server.type === 'http' && server.url) { - config.url = server.url; - if (server.headers) config.headers = server.headers; - } else { - if (server.command) config.command = server.command; - if (server.args) config.args = transformArgs(server.args); - if (server.env && Object.keys(server.env).length > 0) { - config.env = transformEnv(server.env); - } - } - - mcpServers[server.name] = config; - } - - const content = JSON.stringify({ mcpServers }, null, 2); - return { - path: '.cursor/mcp.json', - content, - type: 'mcp', - }; -} - -function convertToAntigravity(mcp: MCPConfig): ConvertedFile { - // Antigravity uses a dedicated mcp_config.json (not the legacy Gemini CLI - // settings.json). Remote servers use `serverUrl` (not `url`/`httpUrl`). - // See github.com/github/github-mcp-server install-antigravity guide. - const mcpServers: Record = {}; - - for (const server of mcp.servers) { - const config: Record = {}; - - if (server.type === 'http' && server.url) { - config.serverUrl = server.url; - if (server.headers) config.headers = server.headers; - } else { - if (server.command) config.command = server.command; - if (server.args) config.args = transformArgs(server.args); - if (server.env && Object.keys(server.env).length > 0) { - config.env = transformEnv(server.env); - } - } - - mcpServers[server.name] = config; - } - - const content = JSON.stringify({ mcpServers }, null, 2); - return { - path: '.agents/mcp_config.json', - content, - type: 'mcp', - }; -} diff --git a/src/converter/pluginManifest.ts b/src/converter/pluginManifest.ts deleted file mode 100644 index 0badbc1..0000000 --- a/src/converter/pluginManifest.ts +++ /dev/null @@ -1,232 +0,0 @@ -import type { - PluginMeta, - PluginInterface, - MarketplaceMeta, - ConvertedFile, - ScanResult, - PluginScanResult, - PlatformPaths, -} from '../types.js'; - -// ─── Platform resource paths (single source of truth) ─── -// These must match the actual output paths in each platform's writer/converter. -// -// Only codex and cursor support plugin manifest/marketplace generation. -// OpenCode's plugin system is fundamentally different (npm packages / .ts modules). -// Antigravity does not support plugins. -// Other platforms still perform normal resource conversion (skills, agents, etc.). - -type ManifestPlatform = 'codex' | 'cursor'; - -const PLATFORM_PATHS: Record = { - codex: { - pluginJson: '.codex-plugin/plugin.json', - marketplaceJson: '.agents/plugins/marketplace.json', - skills: './.agents/skills/', - agents: './.codex/agents/', - mcp: './.codex/config.toml', - }, - cursor: { - pluginJson: '.cursor-plugin/plugin.json', - marketplaceJson: '.cursor-plugin/marketplace.json', - skills: './skills/', - agents: './agents/', - commands: './commands/', - instructions: './rules/', - mcp: './mcp.json', - hooks: './hooks/hooks-cursor.json', - }, -}; - -export { PLATFORM_PATHS, type ManifestPlatform }; - -// ─── Codex ─── - -/** - * Generate .codex-plugin/plugin.json manifest from Claude plugin metadata. - */ -export function convertPluginManifestForCodex( - scan: ScanResult, - meta?: PluginMeta, -): ConvertedFile { - const manifest: Record = { - name: meta?.name || 'converted-plugin', - version: meta?.version || '1.0.0', - description: meta?.description || 'Converted from Claude Code plugin via acplugin', - }; - - if (meta?.author) manifest.author = meta.author; - if (meta?.homepage) manifest.homepage = meta.homepage; - if (meta?.repository) manifest.repository = meta.repository; - if (meta?.license) manifest.license = meta.license; - if (meta?.keywords) manifest.keywords = meta.keywords; - - const paths = PLATFORM_PATHS.codex; - - // Resource path references (derived from platform paths) - if (scan.skills.length > 0 && paths.skills) manifest.skills = paths.skills; - if (scan.mcp && paths.mcp) manifest.mcpServers = paths.mcp; - if (meta?.apps) manifest.apps = './.app.json'; - - // Interface (marketplace display metadata) - if (meta?.interface) { - manifest.interface = buildCodexInterface(meta.interface); - } - - return { - path: paths.pluginJson, - content: JSON.stringify(manifest, null, 2), - type: 'manifest', - }; -} - -function buildCodexInterface(iface: PluginInterface): Record { - const result: Record = {}; - if (iface.displayName) result.displayName = iface.displayName; - if (iface.shortDescription) result.shortDescription = iface.shortDescription; - if (iface.longDescription) result.longDescription = iface.longDescription; - if (iface.developerName) result.developerName = iface.developerName; - if (iface.category) result.category = iface.category; - if (iface.capabilities) result.capabilities = iface.capabilities; - if (iface.websiteURL) result.websiteURL = iface.websiteURL; - if (iface.privacyPolicyURL) result.privacyPolicyURL = iface.privacyPolicyURL; - if (iface.termsOfServiceURL) result.termsOfServiceURL = iface.termsOfServiceURL; - if (iface.defaultPrompt) result.defaultPrompt = iface.defaultPrompt; - if (iface.brandColor) result.brandColor = iface.brandColor; - if (iface.composerIcon) result.composerIcon = iface.composerIcon; - if (iface.logo) result.logo = iface.logo; - if (iface.screenshots) result.screenshots = iface.screenshots; - return result; -} - -/** - * Generate Codex marketplace.json from Claude marketplace metadata. - */ -export function convertMarketplaceForCodex( - marketplace: MarketplaceMeta, - plugins: PluginScanResult[], -): ConvertedFile { - const paths = PLATFORM_PATHS.codex; - const output: Record = { - name: marketplace.name, - }; - - // Interface with displayName - const displayName = marketplace.metadata?.description || marketplace.name; - output.interface = { displayName }; - - output.plugins = plugins.map((p) => { - const entry: Record = { - name: p.meta.name, - source: { - source: 'local', - path: `./plugins/${p.meta.name}`, - }, - policy: { - installation: 'AVAILABLE', - authentication: 'ON_INSTALL', - }, - }; - const category = p.meta.category || p.meta.interface?.category; - if (category) entry.category = category; - return entry; - }); - - return { - path: paths.marketplaceJson!, - content: JSON.stringify(output, null, 2), - type: 'manifest', - }; -} - -// ─── Cursor ─── - -/** - * Generate .cursor-plugin/plugin.json manifest from Claude plugin metadata. - * Enhanced version that includes interface fields. - */ -export function convertPluginManifestForCursor( - scan: ScanResult, - meta?: PluginMeta, -): ConvertedFile { - const manifest: Record = { - name: meta?.name || 'converted-plugin', - }; - - if (meta?.displayName) manifest.displayName = meta.displayName; - // Also pull displayName from interface if not at top level - if (!manifest.displayName && meta?.interface?.displayName) { - manifest.displayName = meta.interface.displayName; - } - - manifest.description = meta?.description || 'Converted from Claude Code plugin via acplugin'; - manifest.version = meta?.version || '1.0.0'; - - if (meta?.author) manifest.author = meta.author; - if (meta?.homepage) manifest.homepage = meta.homepage; - if (meta?.repository) manifest.repository = meta.repository; - if (meta?.license) manifest.license = meta.license; - if (meta?.keywords) manifest.keywords = meta.keywords; - - const paths = PLATFORM_PATHS.cursor; - - // Resource paths for existing components (derived from platform paths) - if (scan.skills.length > 0 && paths.skills) manifest.skills = paths.skills; - if (scan.agents.length > 0 && paths.agents) manifest.agents = paths.agents; - if (scan.commands.length > 0 && paths.commands) manifest.commands = paths.commands; - if (scan.instructions.length > 0 && paths.instructions) manifest.rules = paths.instructions; - if (scan.mcp && paths.mcp) manifest.mcpServers = paths.mcp; - if (scan.hooks && paths.hooks) manifest.hooks = paths.hooks; - - // Logo from interface - if (meta?.interface?.logo) manifest.logo = meta.interface.logo; - - return { - path: paths.pluginJson, - content: JSON.stringify(manifest, null, 2), - type: 'manifest', - }; -} - -/** - * Generate Cursor marketplace.json from Claude marketplace metadata. - */ -export function convertMarketplaceForCursor( - marketplace: MarketplaceMeta, - plugins: PluginScanResult[], -): ConvertedFile { - const output: Record = { - name: marketplace.name, - }; - - if (marketplace.owner) output.owner = marketplace.owner; - - // Metadata block - const metadata: Record = {}; - if (marketplace.metadata?.description || marketplace.description) { - metadata.description = marketplace.metadata?.description || marketplace.description; - } - if (marketplace.metadata?.version || marketplace.version) { - metadata.version = marketplace.metadata?.version || marketplace.version; - } - metadata.pluginRoot = 'plugins'; - output.metadata = metadata; - - output.plugins = plugins.map((p) => ({ - name: p.meta.name, - source: p.meta.name, - description: p.meta.description, - })); - - return { - path: PLATFORM_PATHS.cursor.marketplaceJson!, - content: JSON.stringify(output, null, 2), - type: 'manifest', - }; -} - -// OpenCode and Antigravity are intentionally not handled here. -// OpenCode's plugin system uses npm packages / local .ts modules (fundamentally different). -// Antigravity does not support plugins. -// Both platforms still perform normal resource conversion (skills, agents, MCP, etc.) -// through their respective writers. diff --git a/src/converter/skill.ts b/src/converter/skill.ts deleted file mode 100644 index 1f37fb8..0000000 --- a/src/converter/skill.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { Skill, Platform, ConvertedFile } from '../types.js'; -import { stringifyFrontmatter } from '../utils/frontmatter.js'; - -// Claude-specific fields that other platforms don't support -const CLAUDE_ONLY_FIELDS = [ - 'context', 'agent', 'effort', 'model', 'hooks', 'user-invocable', - 'when_to_use', 'disallowed-tools', 'background', 'paths', 'shell', 'arguments', -]; - -function getSkillOutputPath(platform: Platform, dirName: string): string { - switch (platform) { - case 'codex': - return `.agents/skills/${dirName}/SKILL.md`; - case 'opencode': - return `.opencode/skills/${dirName}/SKILL.md`; - case 'cursor': - return `.cursor/skills/${dirName}/SKILL.md`; - case 'antigravity': - // Antigravity CLI workspace convention is .agents/ (plural), not .agent/. - return `.agents/skills/${dirName}/SKILL.md`; - case 'pi': - // Pi adopts the Claude-style Agent Skills standard under .pi/skills/. - return `.pi/skills/${dirName}/SKILL.md`; - } -} - -function convertFrontmatter(skill: Skill, platform: Platform): Record { - const fm = { ...skill.frontmatter } as Record; - - // For Codex: disable-model-invocation maps to a separate openai.yaml - // For now, keep it in frontmatter as other platforms understand it - - // Remove Claude-only fields and add as comments in body - for (const field of CLAUDE_ONLY_FIELDS) { - delete fm[field]; - } - - return fm; -} - -function buildBody(skill: Skill): string { - const claudeFields: string[] = []; - const fm = skill.frontmatter as Record; - - for (const field of CLAUDE_ONLY_FIELDS) { - if (fm[field] !== undefined) { - claudeFields.push(`- ${field}: ${JSON.stringify(fm[field])}`); - } - } - - if (claudeFields.length === 0) return skill.body; - - const comment = `\n\n`; - return skill.body + comment; -} - -export function convertSkill(skill: Skill, platform: Platform): ConvertedFile { - const frontmatter = convertFrontmatter(skill, platform); - const body = buildBody(skill); - const content = stringifyFrontmatter(frontmatter, body); - const outputPath = getSkillOutputPath(platform, skill.dirName); - - return { path: outputPath, content, type: 'skill' }; -} - -/** - * Convert all auxiliary files (references/, scripts/, assets/, etc.) for a skill. - */ -export function convertSkillAuxFiles(skill: Skill, platform: Platform): ConvertedFile[] { - return skill.auxFiles.map(aux => { - const basePath = getSkillOutputPath(platform, skill.dirName); - const dir = basePath.replace(/\/SKILL\.md$/, ''); - return { - path: `${dir}/${aux.relativePath}`, - content: aux.content, - type: 'skill' as const, - }; - }); -} - -export function convertSkillCodexYaml(skill: Skill): ConvertedFile | null { - if (!skill.frontmatter['disable-model-invocation']) return null; - - const yaml = `allow_implicit_invocation: false\n`; - return { - path: `.agents/skills/${skill.dirName}/agents/openai.yaml`, - content: yaml, - type: 'skill', - }; -} diff --git a/src/github.ts b/src/github.ts deleted file mode 100644 index f73787b..0000000 --- a/src/github.ts +++ /dev/null @@ -1,211 +0,0 @@ -import * as https from 'https'; -import * as http from 'http'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { execSync } from 'child_process'; - -export interface GitHubSource { - owner: string; - repo: string; - branch?: string; - subPath?: string; -} - -/** - * Parse a GitHub source string into components. - * - * Supported formats: - * github:owner/repo - * github:owner/repo#branch - * https://github.com/owner/repo - * https://github.com/owner/repo/tree/branch - * https://github.com/owner/repo/tree/branch/sub/path - * owner/repo - * owner/repo#branch - */ -export function parseGitHubSource(source: string): GitHubSource { - let cleaned = source; - - // Strip github: prefix - if (cleaned.startsWith('github:')) { - cleaned = cleaned.slice('github:'.length); - } - - // Handle full GitHub URLs - const urlMatch = cleaned.match( - /^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?(?:\/tree\/([^/]+)(?:\/(.+))?)?$/ - ); - if (urlMatch) { - return { - owner: urlMatch[1], - repo: urlMatch[2], - branch: urlMatch[3] || undefined, - subPath: urlMatch[4] || undefined, - }; - } - - // Handle owner/repo#branch format - let branch: string | undefined; - const hashIdx = cleaned.indexOf('#'); - if (hashIdx !== -1) { - branch = cleaned.slice(hashIdx + 1); - cleaned = cleaned.slice(0, hashIdx); - } - - const parts = cleaned.split('/'); - if (parts.length < 2) { - throw new Error( - `Invalid GitHub source: "${source}". Expected format: github:owner/repo or owner/repo` - ); - } - - return { - owner: parts[0], - repo: parts[1], - branch, - }; -} - -/** - * Download a GitHub repo to a temp directory. - * Prefers `git clone --recurse-submodules` (handles submodules properly). - * Falls back to tarball download if git is unavailable. - * Returns the path to the extracted/cloned directory. - */ -export async function downloadGitHubRepo(source: GitHubSource): Promise { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'acplugin-')); - - // Try git clone first (supports submodules) - if (isGitAvailable()) { - return cloneWithGit(source, tmpDir); - } - - // Fallback: tarball download (no submodule support) - return downloadTarball(source, tmpDir); -} - -function isGitAvailable(): boolean { - try { - execSync('git --version', { stdio: 'pipe' }); - return true; - } catch { - return false; - } -} - -function cloneWithGit(source: GitHubSource, tmpDir: string): string { - const repoUrl = `https://github.com/${source.owner}/${source.repo}.git`; - const cloneDir = path.join(tmpDir, source.repo); - - const args = ['clone', '--depth', '1', '--recurse-submodules', '--shallow-submodules']; - if (source.branch) { - args.push('--branch', source.branch); - } - args.push(repoUrl, cloneDir); - - execSync(`git ${args.join(' ')}`, { stdio: 'pipe' }); - - let repoDir = cloneDir; - if (source.subPath) { - const subDir = path.join(repoDir, source.subPath); - if (!fs.existsSync(subDir)) { - throw new Error(`Sub-path "${source.subPath}" not found in repository`); - } - repoDir = subDir; - } - - return repoDir; -} - -async function downloadTarball(source: GitHubSource, tmpDir: string): Promise { - const branch = source.branch || 'HEAD'; - const tarballUrl = `https://api.github.com/repos/${source.owner}/${source.repo}/tarball/${branch}`; - const tarballPath = path.join(tmpDir, 'repo.tar.gz'); - - // Download tarball (follow redirects) - await downloadFile(tarballUrl, tarballPath); - - // Extract tarball - execSync(`tar -xzf "${tarballPath}" -C "${tmpDir}"`, { stdio: 'pipe' }); - - // Find the extracted directory (GitHub tarballs have a top-level dir like owner-repo-sha) - const entries = fs.readdirSync(tmpDir, { withFileTypes: true }); - const extractedDir = entries.find(e => e.isDirectory()); - if (!extractedDir) { - throw new Error('Failed to extract repository archive'); - } - - let repoDir = path.join(tmpDir, extractedDir.name); - - // If subPath specified, point to that subdirectory - if (source.subPath) { - const subDir = path.join(repoDir, source.subPath); - if (!fs.existsSync(subDir)) { - throw new Error(`Sub-path "${source.subPath}" not found in repository`); - } - repoDir = subDir; - } - - // Clean up tarball - fs.unlinkSync(tarballPath); - - return repoDir; -} - -/** - * Clean up a temporary directory created by downloadGitHubRepo. - */ -export function cleanupTempDir(tmpDir: string): void { - // Safety: only delete if it's in the system temp directory - if (tmpDir.startsWith(os.tmpdir())) { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } -} - -/** - * Get the root temp dir from an extracted repo path (for cleanup). - */ -export function getTempRoot(repoDir: string): string { - const tmpBase = os.tmpdir(); - const relative = path.relative(tmpBase, repoDir); - const firstSegment = relative.split(path.sep)[0]; - return path.join(tmpBase, firstSegment); -} - -function downloadFile(url: string, destPath: string, redirectCount = 0): Promise { - if (redirectCount > 5) { - return Promise.reject(new Error('Too many redirects')); - } - - return new Promise((resolve, reject) => { - const proto = url.startsWith('https') ? https : http; - const req = proto.get(url, { - headers: { - 'User-Agent': 'acplugin/1.0', - 'Accept': 'application/vnd.github+json', - ...(process.env.GITHUB_TOKEN ? { 'Authorization': `Bearer ${process.env.GITHUB_TOKEN}` } : {}), - }, - }, (res) => { - // Follow redirects - if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { - resolve(downloadFile(res.headers.location, destPath, redirectCount + 1)); - return; - } - - if (res.statusCode !== 200) { - reject(new Error(`GitHub API returned ${res.statusCode}. Check that the repository exists and is accessible.`)); - return; - } - - const fileStream = fs.createWriteStream(destPath); - res.pipe(fileStream); - fileStream.on('finish', () => { - fileStream.close(); - resolve(); - }); - fileStream.on('error', reject); - }); - req.on('error', reject); - }); -} diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 7631f76..0000000 --- a/src/index.ts +++ /dev/null @@ -1,403 +0,0 @@ -#!/usr/bin/env node - -import { Command } from 'commander'; -import * as path from 'path'; -import * as fs from 'fs'; -import { scanClaudeProject } from './scanner/claude.js'; -import { hasMarketplace, isSinglePlugin, scanAllPlugins, scanPlugin, countResources, scanMarketplaceFull } from './scanner/plugin.js'; -import { generateCodex } from './writer/codex.js'; -import { generateOpenCode } from './writer/opencode.js'; -import { generateCursor } from './writer/cursor.js'; -import { generateAntigravity } from './writer/antigravity.js'; -import { generatePi } from './writer/pi.js'; -import { writeFile } from './utils/fs.js'; -import { parseGitHubSource, downloadGitHubRepo, cleanupTempDir, getTempRoot } from './github.js'; -import { selectPlugins, selectPlatforms, runWizard, log } from './tui.js'; -import type { Platform, ConvertResult, ScanResult, PluginScanResult, MarketplaceScanResult } from './types.js'; -import { convertMarketplaceForCodex, convertMarketplaceForCursor } from './converter/pluginManifest.js'; - -const program = new Command(); - -program - .name('acplugin') - .description('Convert Claude Code plugins to Codex, OpenCode, and Cursor formats') - .version('1.1.0'); - -/** - * Detect if source is a GitHub repo or local path. - */ -function isGitHubSource(source: string): boolean { - if (source.startsWith('github:')) return true; - if (source.startsWith('https://github.com/')) return true; - if (source.startsWith('http://github.com/')) return true; - // owner/repo pattern: contains exactly one slash, no dots or path separators at start - if (/^[a-zA-Z0-9_-]+\/[a-zA-Z0-9._-]+$/.test(source)) { - // Check it's not a local path that exists - if (!fs.existsSync(source)) return true; - } - return false; -} - -/** - * Resolve source to a local directory path. - */ -async function resolveSource(source: string, subPath?: string): Promise<[string, (() => void) | null]> { - if (isGitHubSource(source)) { - const ghSource = parseGitHubSource(source); - if (subPath) ghSource.subPath = subPath; - log.info(`Downloading ${ghSource.owner}/${ghSource.repo}${ghSource.branch ? `#${ghSource.branch}` : ''}...`); - const repoDir = await downloadGitHubRepo(ghSource); - const tempRoot = getTempRoot(repoDir); - log.success('Downloaded and extracted'); - return [repoDir, () => cleanupTempDir(tempRoot)]; - } - - return [path.resolve(source), null]; -} - -/** - * Detect source type and scan. - */ -function detectAndScan(rootDir: string): { type: 'marketplace'; plugins: PluginScanResult[]; marketplaceMeta: MarketplaceScanResult | null } - | { type: 'plugin'; scan: PluginScanResult } - | { type: 'project'; scan: ScanResult } { - - if (hasMarketplace(rootDir)) { - const full = scanMarketplaceFull(rootDir); - return { - type: 'marketplace', - plugins: full?.plugins || scanAllPlugins(rootDir), - marketplaceMeta: full, - }; - } - if (isSinglePlugin(rootDir)) { - return { type: 'plugin', scan: scanPlugin(rootDir) }; - } - return { type: 'project', scan: scanClaudeProject(rootDir) }; -} - -// --- scan command --- -program - .command('scan') - .description('Scan and list convertible resources') - .argument('[source]', 'Local path or GitHub repo (owner/repo)', '.') - .option('-p, --path ', 'Sub-path within the repository') - .action(async (source: string, opts: { path?: string }) => { - const [rootDir, cleanup] = await resolveSource(source, opts.path); - try { - const detected = detectAndScan(rootDir); - - if (detected.type === 'marketplace') { - printMarketplaceScan(detected.plugins); - } else if (detected.type === 'plugin') { - log.header(`Plugin: ${detected.scan.meta.name}`); - printScanResult(detected.scan); - } else { - printScanResult(detected.scan); - } - } finally { - cleanup?.(); - } - }); - -// --- convert command --- -program - .command('convert') - .description('Convert Claude Code plugins to other platforms') - .argument('[source]', 'Local path or GitHub repo (owner/repo)', '.') - .option('-t, --to ', 'Target platforms (codex,opencode,cursor,antigravity,pi)') - .option('-o, --output ', 'Output directory') - .option('-a, --all', 'Convert all plugins without selection') - .option('-p, --path ', 'Sub-path within the repository') - .option('--dry-run', 'Preview without writing files') - .action(async (source: string, opts: { to?: string; output?: string; all?: boolean; path?: string; dryRun?: boolean }) => { - const [rootDir, cleanup] = await resolveSource(source, opts.path); - - try { - const outputDir = opts.output - ? path.resolve(opts.output) - : (isGitHubSource(source) ? path.resolve('.') : rootDir); - - // Resolve platforms - let platforms: Platform[]; - if (opts.to) { - platforms = opts.to.split(',').map(p => p.trim()) as Platform[]; - const valid: Platform[] = ['codex', 'opencode', 'cursor', 'antigravity', 'pi']; - for (const p of platforms) { - if (!valid.includes(p)) { - log.error(`Unknown platform "${p}". Valid: ${valid.join(', ')}`); - process.exit(1); - } - } - } else { - // Interactive platform selection - platforms = await selectPlatforms(); - if (platforms.length === 0) { - log.warn('No platforms selected.'); - return; - } - } - - const dryRun = opts.dryRun || false; - const detected = detectAndScan(rootDir); - - if (detected.type === 'marketplace') { - await convertMarketplace(detected.plugins, platforms, outputDir, dryRun, opts.all || false, detected.marketplaceMeta); - } else if (detected.type === 'plugin') { - log.header(detected.scan.meta.name); - convertSingleScan(detected.scan, platforms, outputDir, dryRun); - } else { - convertSingleScan(detected.scan, platforms, outputDir, dryRun); - } - } finally { - cleanup?.(); - } - }); - -// --- Marketplace conversion --- - -async function convertMarketplace( - plugins: PluginScanResult[], - platforms: Platform[], - outputDir: string, - dryRun: boolean, - all: boolean, - marketplaceMeta?: MarketplaceScanResult | null, -): Promise { - if (plugins.length === 0) { - log.warn('No plugins with convertible resources found.'); - return; - } - - log.success(`Found ${plugins.length} plugin(s)`); - - let selectedIndices: number[]; - if (all) { - selectedIndices = plugins.map((_, i) => i); - } else { - selectedIndices = await selectPlugins(plugins); - if (selectedIndices.length === 0) { - log.warn('No plugins selected.'); - return; - } - } - - // Determine whether to use subdirectories for each plugin - let useSubDirs = selectedIndices.length > 1; - if (!useSubDirs && selectedIndices.length === 1 && process.stdin.isTTY) { - const { confirm } = require('@inquirer/prompts') as { confirm: Function }; - useSubDirs = await confirm({ - message: `Output to subdirectory "${plugins[selectedIndices[0]].meta.name}/"?`, - default: false, - }); - } - - log.info(`Converting ${selectedIndices.length} plugin(s) to ${platforms.join(', ')}...`); - - let totalFiles = 0; - const selectedPlugins = selectedIndices.map(i => plugins[i]); - - for (const plugin of selectedPlugins) { - const pluginOutputDir = useSubDirs - ? path.join(outputDir, plugin.meta.name) - : outputDir; - log.header(plugin.meta.name); - totalFiles += convertSingleScan(plugin, platforms, pluginOutputDir, dryRun); - } - - // Generate marketplace manifest files for each platform - if (marketplaceMeta?.marketplace) { - for (const platform of platforms) { - const marketplaceFiles = generateMarketplaceManifest( - platform, - marketplaceMeta.marketplace, - selectedPlugins, - ); - for (const file of marketplaceFiles.files) { - if (!dryRun) { - writeFile(path.join(outputDir, file.path), file.content); - } - totalFiles++; - } - if (marketplaceFiles.files.length > 0) { - log.stat(`${platform} marketplace`, `${marketplaceFiles.files.length} file(s)`); - } - for (const w of marketplaceFiles.warnings) { - log.warn(w); - } - } - } - - console.log(); - const verb = dryRun ? 'Would generate' : 'Generated'; - log.success(`${verb} ${totalFiles} file(s) for ${selectedIndices.length} plugin(s)`); -} - -// --- Single scan conversion --- - -function convertSingleScan( - scan: ScanResult, - platforms: Platform[], - outputDir: string, - dryRun: boolean, -): number { - const totalResources = scan.skills.length + scan.instructions.length + - (scan.mcp ? scan.mcp.servers.length : 0) + scan.agents.length + - scan.commands.length + (scan.hooks ? Object.keys(scan.hooks).length : 0); - - if (totalResources === 0) { - log.warn('No resources found.'); - return 0; - } - - log.stat('Resources', totalResources); - - let totalFiles = 0; - const results: ConvertResult[] = []; - - for (const platform of platforms) { - const result = generateForPlatform(scan, platform); - results.push(result); - totalFiles += result.files.length; - - if (!dryRun) { - for (const file of result.files) { - writeFile(path.join(outputDir, file.path), file.content); - } - } - } - - printConvertReport(results, dryRun); - return totalFiles; -} - -function generateForPlatform(scan: ScanResult, platform: Platform): ConvertResult { - switch (platform) { - case 'codex': return generateCodex(scan); - case 'opencode': return generateOpenCode(scan); - case 'cursor': return generateCursor(scan); - case 'antigravity': return generateAntigravity(scan); - case 'pi': return generatePi(scan); - } -} - -/** - * Generate marketplace manifest files for a target platform. - */ -function generateMarketplaceManifest( - platform: Platform, - marketplace: import('./types.js').MarketplaceMeta, - plugins: PluginScanResult[], -): { files: import('./types.js').ConvertedFile[]; warnings: string[] } { - const files: import('./types.js').ConvertedFile[] = []; - const warnings: string[] = []; - - switch (platform) { - case 'codex': - files.push(convertMarketplaceForCodex(marketplace, plugins)); - break; - case 'cursor': - files.push(convertMarketplaceForCursor(marketplace, plugins)); - break; - // OpenCode and Antigravity don't support plugin manifest/marketplace. - // Their resource conversion (skills, agents, etc.) is handled normally by the writers. - } - - return { files, warnings }; -} - -// --- Print functions --- - -function printMarketplaceScan(plugins: PluginScanResult[]): void { - log.header('Claude Code Plugin Marketplace'); - log.success(`Found ${plugins.length} plugin(s) with resources`); - console.log(); - - for (let i = 0; i < plugins.length; i++) { - const p = plugins[i]; - const resources = countResources(p); - const category = p.meta.category ? ` [${p.meta.category}]` : ''; - log.plugin(`${i + 1}. ${p.meta.name}${category}`, `${resources} resource(s)`); - if (p.meta.description) { - log.dim(` ${p.meta.description}`); - } - - const parts: string[] = []; - if (p.skills.length) parts.push(`${p.skills.length} skill(s)`); - if (p.agents.length) parts.push(`${p.agents.length} agent(s)`); - if (p.commands.length) parts.push(`${p.commands.length} command(s)`); - if (p.hooks) parts.push(`${Object.keys(p.hooks).length} hook event(s)`); - if (parts.length) log.dim(` ${parts.join(', ')}`); - console.log(); - } -} - -function printScanResult(scan: ScanResult): void { - const sections: [string, number][] = [ - ['Skills', scan.skills.length], - ['Instructions', scan.instructions.length], - ['MCP Servers', scan.mcp?.servers.length || 0], - ['Agents', scan.agents.length], - ['Commands', scan.commands.length], - ['Hook Events', scan.hooks ? Object.keys(scan.hooks).length : 0], - ]; - - for (const [label, count] of sections) { - if (count > 0) log.stat(label, count); - } - - if (scan.skills.length) { - for (const s of scan.skills) log.file(`skill: ${s.frontmatter.name || s.dirName}`); - } - if (scan.agents.length) { - for (const a of scan.agents) log.file(`agent: ${a.frontmatter.name || a.fileName}`); - } - if (scan.commands.length) { - for (const c of scan.commands) log.file(`command: /${c.name}`); - } -} - -function printConvertReport(results: ConvertResult[], dryRun: boolean): void { - for (const result of results) { - const name = result.platform.charAt(0).toUpperCase() + result.platform.slice(1); - - if (dryRun) { - log.dim(` ${name}: ${result.files.length} file(s)`); - for (const f of result.files) log.file(f.path); - } else { - log.stat(name, `${result.files.length} file(s)`); - } - - for (const w of result.warnings) { - log.warn(w); - } - } -} - -// --- Default: interactive wizard when no subcommand --- -async function main() { - // If no subcommand provided (just `acplugin`), run interactive wizard - const args = process.argv.slice(2); - const hasSubcommand = args.length > 0 && ['scan', 'convert', 'help', '--help', '-h', '--version', '-V'].includes(args[0]); - - if (args.length === 0 || !hasSubcommand) { - if (args.length === 0 && process.stdin.isTTY) { - // Pure `acplugin` with no args → wizard - const result = await runWizard(); - const fakeArgs = [result.action, result.source]; - - if (result.action === 'convert') { - if (result.platforms.length) fakeArgs.push('--to', result.platforms.join(',')); - if (result.outputDir) fakeArgs.push('-o', result.outputDir); - if (result.all) fakeArgs.push('--all'); - if (result.dryRun) fakeArgs.push('--dry-run'); - } - - process.argv = ['node', 'acplugin', ...fakeArgs]; - } - } - - program.parse(); -} - -main(); diff --git a/src/scanner/plugin.ts b/src/scanner/plugin.ts deleted file mode 100644 index 114683b..0000000 --- a/src/scanner/plugin.ts +++ /dev/null @@ -1,326 +0,0 @@ -import * as path from 'path'; -import { readFile, fileExists, listDirs, listFilesRecursive } from '../utils/fs.js'; -import { scanSkillsDir, scanAgentsDir, scanCommandsDir, scanHooksJson, scanMCPJson } from './claude.js'; -import type { PluginMeta, PluginScanResult, MarketplaceMeta, MarketplaceScanResult, MCPConfig, PluginResourceFile } from '../types.js'; - -/** - * Check if a directory contains a Claude Code plugin marketplace. - */ -export function hasMarketplace(rootDir: string): boolean { - return fileExists(path.join(rootDir, '.claude-plugin', 'marketplace.json')); -} - -/** - * Check if a directory is a single plugin (has .claude-plugin/plugin.json). - */ -export function isSinglePlugin(rootDir: string): boolean { - return fileExists(path.join(rootDir, '.claude-plugin', 'plugin.json')); -} - -/** - * Scan marketplace.json and return full marketplace metadata. - */ -export function scanMarketplaceMeta(rootDir: string): MarketplaceMeta | null { - const marketplacePath = path.join(rootDir, '.claude-plugin', 'marketplace.json'); - const content = readFile(marketplacePath); - if (!content) return null; - - try { - const data = JSON.parse(content); - return { - name: data.name || 'marketplace', - version: data.version, - description: data.description, - owner: data.owner, - metadata: data.metadata, - plugins: (data.plugins || []).map((p: any) => ({ - name: p.name, - source: p.source, - description: p.description, - version: p.version, - category: p.category, - })), - }; - } catch { - return null; - } -} - -/** - * Scan marketplace.json and return plugin metadata with resolved paths. - */ -export function scanMarketplace(rootDir: string): PluginMeta[] { - const marketplace = scanMarketplaceMeta(rootDir); - if (!marketplace) return []; - - return marketplace.plugins.map((p) => ({ - name: p.name, - description: p.description, - version: p.version, - source: p.source, - category: p.category, - })); -} - -/** - * Resolve the actual directory path for a plugin from its marketplace source field. - * When pluginRoot is set (e.g. "plugins"), source is a short name (e.g. "my-plugin") - * and resolves to rootDir/plugins/my-plugin. - */ -export function resolvePluginDir(rootDir: string, source: string, pluginRoot?: string): string { - if (pluginRoot) { - return path.resolve(rootDir, pluginRoot, source); - } - // source is like "./plugins/code-review" or "./skills" - return path.resolve(rootDir, source); -} - -/** - * Scan a single plugin directory. - * Plugin structure has skills/agents/commands/hooks directly in root (not under .claude/). - * Respects custom resource paths from plugin.json when available. - */ -export function scanPlugin(pluginDir: string, meta?: PluginMeta): PluginScanResult { - // Read plugin.json for metadata if not provided - const resolvedMeta = meta || readPluginMeta(pluginDir); - - // Resolve resource paths: use custom paths from meta if available, fallback to defaults - const skillsDir = resolvedMeta.skills - ? path.resolve(pluginDir, resolvedMeta.skills) - : path.join(pluginDir, 'skills'); - - const agentsDir = resolvedMeta.agents - ? path.resolve(pluginDir, resolvedMeta.agents as string) - : path.join(pluginDir, 'agents'); - - const commandsPath = resolvedMeta.commands; - const commandsDir = typeof commandsPath === 'string' && !commandsPath.endsWith('.md') - ? path.resolve(pluginDir, commandsPath) - : path.join(pluginDir, 'commands'); - - const hooksPath = resolvedMeta.hooks - ? path.resolve(pluginDir, resolvedMeta.hooks) - : path.join(pluginDir, 'hooks', 'hooks.json'); - - // MCP: use custom path from meta, fallback to .mcp.json in plugin root - const mcpPath = resolvedMeta.mcpServers - ? path.resolve(pluginDir, resolvedMeta.mcpServers) - : path.join(pluginDir, '.mcp.json'); - const mcpConfig = scanMCPJson(mcpPath); - - // Scan plugin-level resource files referenced by MCP config (e.g. scripts/) - const pluginFiles = scanMCPReferencedFiles(pluginDir, mcpConfig); - - return { - meta: resolvedMeta, - skills: scanSkillsDir(skillsDir), - instructions: [], - mcp: mcpConfig, - agents: scanAgentsDir(agentsDir), - commands: scanCommandsDir(commandsDir), - hooks: scanHooksJson(hooksPath), - pluginFiles, - rootDir: pluginDir, - }; -} - -/** - * Read plugin.json metadata from a plugin directory. - * Extracts both metadata fields and resource path overrides. - */ -export function readPluginMeta(pluginDir: string): PluginMeta { - const pluginJsonPath = path.join(pluginDir, '.claude-plugin', 'plugin.json'); - const content = readFile(pluginJsonPath); - if (!content) { - return { name: path.basename(pluginDir) }; - } - - try { - const data = JSON.parse(content); - const meta: PluginMeta = { - name: data.name || path.basename(pluginDir), - description: data.description, - version: data.version, - author: data.author, - displayName: data.displayName, - homepage: data.homepage, - repository: data.repository, - license: data.license, - keywords: data.keywords, - }; - - // Resource path overrides - if (data.skills) meta.skills = data.skills; - if (data.agents) meta.agents = data.agents; - if (data.commands) meta.commands = data.commands; - if (data.hooks) meta.hooks = data.hooks; - if (data.mcpServers) meta.mcpServers = data.mcpServers; - if (data.apps) meta.apps = data.apps; - - // Marketplace display metadata - if (data.interface) meta.interface = data.interface; - - return meta; - } catch { - return { name: path.basename(pluginDir) }; - } -} - -/** - * Extract file/directory paths referenced by ${CLAUDE_PLUGIN_ROOT} in MCP config, - * then scan those paths recursively and return as PluginResourceFile[]. - */ -function scanMCPReferencedFiles(pluginDir: string, mcp: MCPConfig | null): PluginResourceFile[] { - if (!mcp) return []; - const referencedDirs = new Set(); - - for (const server of mcp.servers) { - // Extract from args - for (const arg of server.args || []) { - const matches = arg.matchAll(/\$\{CLAUDE_PLUGIN_ROOT\}\/([^\s"]+)/g); - for (const m of matches) { - referencedDirs.add(m[1].split('/')[0]); - } - } - // Extract from env values - for (const val of Object.values(server.env || {})) { - const matches = val.matchAll(/\$\{CLAUDE_PLUGIN_ROOT\}\/([^\s"]+)/g); - for (const m of matches) { - referencedDirs.add(m[1].split('/')[0]); - } - } - } - - const files: PluginResourceFile[] = []; - for (const dirName of referencedDirs) { - const dirPath = path.join(pluginDir, dirName); - if (!fileExists(dirPath)) continue; - for (const file of listFilesRecursive(dirPath)) { - const content = readFile(file); - if (content !== null) { - files.push({ - relativePath: path.relative(pluginDir, file).replace(/\\/g, '/'), - content, - }); - } - } - } - - return files; -} - -/** - * Analyze what a marketplace source directory contains. - * - * Priority: - * 1. Has .claude-plugin/plugin.json → plugin root (most explicit) - * 2. Has skills/ or agents/ subdirectory → plugin root (standard layout) - * 3. Directory name matches "skills"/"agents"/"commands" → direct resource dir - * 4. Subdirectories contain SKILL.md → skills directory (content detection) - * 5. None of the above → unknown, treat as plugin root - */ -type SourceTargetType = 'plugin-root' | 'skills-dir' | 'agents-dir' | 'commands-dir' | 'unknown'; - -export function analyzeSourceTarget(dir: string): SourceTargetType { - // Has .claude-plugin/plugin.json → explicit plugin root - if (fileExists(path.join(dir, '.claude-plugin', 'plugin.json'))) return 'plugin-root'; - - // Has skills/ or agents/ subdirectory → standard plugin root layout - if (fileExists(path.join(dir, 'skills')) || fileExists(path.join(dir, 'agents'))) return 'plugin-root'; - - // Infer resource type from directory name - const dirName = path.basename(dir).toLowerCase(); - if (dirName === 'skills') return 'skills-dir'; - if (dirName === 'agents') return 'agents-dir'; - if (dirName === 'commands') return 'commands-dir'; - - // Content detection: subdirectories contain SKILL.md → skills directory - const subdirs = listDirs(dir); - for (const sub of subdirs) { - if (fileExists(path.join(sub, 'SKILL.md'))) return 'skills-dir'; - } - - return 'unknown'; -} - -/** - * Scan all plugins in a marketplace repo. - * Analyzes each source target to determine if it's a plugin root or a direct - * resource directory, then routes to the appropriate scanning strategy. - */ -export function scanAllPlugins(rootDir: string): PluginScanResult[] { - const marketplace = scanMarketplaceMeta(rootDir); - if (!marketplace) return []; - - const pluginRoot = marketplace.metadata?.pluginRoot; - const results: PluginScanResult[] = []; - - for (const entry of marketplace.plugins) { - if (!entry.source) continue; - const pluginDir = resolvePluginDir(rootDir, entry.source, pluginRoot); - if (!fileExists(pluginDir)) continue; - - const meta: PluginMeta = { - name: entry.name, - description: entry.description, - version: entry.version, - source: entry.source, - category: entry.category, - }; - - const targetType = analyzeSourceTarget(pluginDir); - let result: PluginScanResult; - - switch (targetType) { - case 'skills-dir': - result = { - meta, skills: scanSkillsDir(pluginDir), - instructions: [], mcp: null, agents: [], commands: [], hooks: null, pluginFiles: [], rootDir: pluginDir, - }; - break; - case 'agents-dir': - result = { - meta, agents: scanAgentsDir(pluginDir), - skills: [], instructions: [], mcp: null, commands: [], hooks: null, pluginFiles: [], rootDir: pluginDir, - }; - break; - case 'commands-dir': - result = { - meta, commands: scanCommandsDir(pluginDir), - skills: [], instructions: [], mcp: null, agents: [], hooks: null, pluginFiles: [], rootDir: pluginDir, - }; - break; - default: // 'plugin-root' | 'unknown' - result = scanPlugin(pluginDir, meta); - } - - // Only include plugins that have actual resources - const resourceCount = result.skills.length + result.agents.length + - result.commands.length + (result.hooks ? Object.keys(result.hooks).length : 0); - if (resourceCount > 0) { - results.push(result); - } - } - - return results; -} - -/** - * Scan marketplace and return full MarketplaceScanResult with metadata. - */ -export function scanMarketplaceFull(rootDir: string): MarketplaceScanResult | null { - const marketplace = scanMarketplaceMeta(rootDir); - if (!marketplace) return null; - - const plugins = scanAllPlugins(rootDir); - return { marketplace, plugins }; -} - -/** - * Count total resources in a PluginScanResult. - */ -export function countResources(scan: PluginScanResult): number { - return scan.skills.length + scan.agents.length + - scan.commands.length + (scan.hooks ? Object.keys(scan.hooks).length : 0) + - scan.instructions.length + (scan.mcp ? scan.mcp.servers.length : 0); -} diff --git a/src/tui.ts b/src/tui.ts deleted file mode 100644 index a44e2f6..0000000 --- a/src/tui.ts +++ /dev/null @@ -1,191 +0,0 @@ -import chalk from 'chalk'; -import type { PluginScanResult, Platform } from './types.js'; -import { countResources } from './scanner/plugin.js'; - -const { checkbox, select, input, confirm } = require('@inquirer/prompts') as { - checkbox: Function; select: Function; input: Function; confirm: Function; -}; - -// --- Interactive wizard (acplugin with no args) --- - -export interface WizardResult { - action: 'scan' | 'convert'; - source: string; - platforms: Platform[]; - outputDir?: string; - all: boolean; - dryRun: boolean; -} - -/** - * Full interactive wizard when running `acplugin` with no arguments. - */ -export async function runWizard(): Promise { - console.log(); - console.log(chalk.bold.cyan(' acplugin') + chalk.dim(' — Claude Code Plugin Converter')); - console.log(); - - // Step 1: Action - const action: 'scan' | 'convert' = await select({ - message: 'What do you want to do?', - choices: [ - { name: `${chalk.green('Convert')} — Convert plugins to other platforms`, value: 'convert' }, - { name: `${chalk.blue('Scan')} — Scan and list available resources`, value: 'scan' }, - ], - }); - - // Step 2: Source - const sourceType: 'local' | 'github' = await select({ - message: 'Where are the plugins?', - choices: [ - { name: `${chalk.yellow('Local')} — Current directory or local path`, value: 'local' }, - { name: `${chalk.magenta('GitHub')} — Download from a GitHub repository`, value: 'github' }, - ], - }); - - let source: string; - if (sourceType === 'github') { - source = await input({ - message: 'GitHub repo (owner/repo):', - validate: (v: string) => v.includes('/') || 'Please enter owner/repo format', - }); - } else { - source = await input({ - message: 'Local path:', - default: '.', - }); - } - - // For scan, we're done - if (action === 'scan') { - return { action, source, platforms: [], all: false, dryRun: false }; - } - - // Step 3: Platforms (convert only) - const platforms = await selectPlatforms(); - if (platforms.length === 0) { - log.warn('No platforms selected, defaulting to all.'); - return { action, source, platforms: ['codex', 'opencode', 'cursor', 'antigravity', 'pi'], all: true, dryRun: false }; - } - - // Step 4: Output directory - const customOutput = await confirm({ - message: 'Use custom output directory?', - default: false, - }); - - let outputDir: string | undefined; - if (customOutput) { - outputDir = await input({ - message: 'Output directory:', - default: './output', - }); - } - - // Step 5: Dry run? - const dryRun = await confirm({ - message: 'Dry run (preview only, no files written)?', - default: false, - }); - - return { action, source, platforms, outputDir, all: false, dryRun }; -} - -// --- Plugin selection --- - -/** - * Interactive plugin selection via checkbox. - */ -export async function selectPlugins(plugins: PluginScanResult[]): Promise { - if (!process.stdin.isTTY) { - return plugins.map((_, i) => i); - } - - const choices = plugins.map((p, i) => { - const resources = countResources(p); - const category = p.meta.category ? chalk.dim(` [${p.meta.category}]`) : ''; - const desc = p.meta.description ? chalk.dim(` — ${p.meta.description}`) : ''; - return { - name: `${p.meta.name}${category} ${chalk.cyan(`(${resources} resources)`)}${desc}`, - value: i, - checked: true, - }; - }); - - const selected: number[] = await checkbox({ - message: 'Select plugins to convert', - choices, - pageSize: 15, - instructions: chalk.dim('(↑↓ navigate, space toggle, a=all, enter=confirm)'), - }); - - return selected; -} - -/** - * Interactive platform selection via checkbox. - */ -export async function selectPlatforms(): Promise { - if (!process.stdin.isTTY) { - return ['codex', 'opencode', 'cursor', 'antigravity', 'pi']; - } - - const choices = [ - { name: 'Codex CLI', value: 'codex' as Platform, checked: true }, - { name: 'OpenCode', value: 'opencode' as Platform, checked: true }, - { name: 'Cursor', value: 'cursor' as Platform, checked: true }, - { name: 'Antigravity (Google)', value: 'antigravity' as Platform, checked: true }, - { name: 'Pi (pi-coding-agent)', value: 'pi' as Platform, checked: true }, - ]; - - const selected: Platform[] = await checkbox({ - message: 'Select target platforms', - choices, - }); - - return selected; -} - -/** - * Parse selection string for non-interactive mode. - */ -export function parseSelection(input: string, total: number): number[] { - const trimmed = input.trim().toLowerCase(); - - if (trimmed === 'all' || trimmed === 'a' || trimmed === '*') { - return Array.from({ length: total }, (_, i) => i); - } - - const indices = new Set(); - const parts = trimmed.split(',').map(s => s.trim()).filter(Boolean); - - for (const part of parts) { - const rangeMatch = part.match(/^(\d+)\s*-\s*(\d+)$/); - if (rangeMatch) { - const start = parseInt(rangeMatch[1], 10); - const end = parseInt(rangeMatch[2], 10); - for (let i = start; i <= end; i++) { - if (i >= 1 && i <= total) indices.add(i - 1); - } - } else { - const num = parseInt(part, 10); - if (!isNaN(num) && num >= 1 && num <= total) indices.add(num - 1); - } - } - - return Array.from(indices).sort((a, b) => a - b); -} - -// --- Styled output helpers --- - -export const log = { - success: (msg: string) => console.log(chalk.green('✔') + ' ' + msg), - error: (msg: string) => console.error(chalk.red('✖') + ' ' + msg), - warn: (msg: string) => console.log(chalk.yellow('⚠') + ' ' + chalk.dim(msg)), - info: (msg: string) => console.log(chalk.blue('ℹ') + ' ' + msg), - dim: (msg: string) => console.log(chalk.dim(msg)), - header: (msg: string) => console.log('\n' + chalk.bold.underline(msg)), - plugin: (name: string, detail: string) => console.log(chalk.bold.cyan(name) + ' ' + chalk.dim(detail)), - file: (path: string) => console.log(' ' + chalk.green(path)), - stat: (label: string, value: string | number) => console.log(` ${chalk.dim(label + ':')} ${chalk.white(String(value))}`), -}; diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index e7508c8..0000000 --- a/src/types.ts +++ /dev/null @@ -1,224 +0,0 @@ -// Target platforms -export type Platform = 'codex' | 'opencode' | 'cursor' | 'antigravity' | 'pi'; - -// --- Platform resource paths (single source of truth) --- -// These must match the actual output paths in each platform's writer/converter. - -export interface PlatformPaths { - pluginJson: string; // manifest output path - marketplaceJson?: string; // marketplace output path - skills?: string; // skills directory reference - agents?: string; // agents directory reference - commands?: string; // commands directory reference - instructions?: string; // instructions file reference - mcp?: string; // MCP config reference - hooks?: string; // hooks config reference -} - -// --- Skill --- -export interface SkillFrontmatter { - name?: string; - description?: string; - 'when_to_use'?: string; - 'argument-hint'?: string; - arguments?: unknown; - 'disable-model-invocation'?: boolean; - 'user-invocable'?: boolean; - 'allowed-tools'?: string; - 'disallowed-tools'?: string; - model?: string; - effort?: string; - context?: string; - agent?: string; - background?: boolean; - paths?: string | string[]; - shell?: string; - hooks?: Record; -} - -export interface SkillAuxFile { - relativePath: string; // relative to skill dir, e.g. "references/doc.md" - content: string; -} - -export interface Skill { - dirName: string; - frontmatter: SkillFrontmatter; - body: string; - sourcePath: string; - auxFiles: SkillAuxFile[]; -} - -// --- Instruction --- -export interface Instruction { - fileName: string; - content: string; - sourcePath: string; - isRule: boolean; // true if from .claude/rules/ -} - -// --- MCP Server --- -export interface MCPServer { - name: string; - command?: string; - args?: string[]; - env?: Record; - type?: string; // 'http' | 'stdio' - url?: string; - headers?: Record; -} - -export interface MCPConfig { - servers: MCPServer[]; - sourcePath: string; -} - -// --- Agent --- -export interface AgentFrontmatter { - name?: string; - description?: string; - tools?: string; - disallowedTools?: string; - model?: string; - permissionMode?: string; - maxTurns?: number; - skills?: string[]; - mcpServers?: unknown[]; - hooks?: Record; - memory?: string; - background?: boolean; - effort?: string; - isolation?: string; - color?: string; - initialPrompt?: string; -} - -export interface Agent { - fileName: string; - frontmatter: AgentFrontmatter; - body: string; - sourcePath: string; -} - -// --- Command --- -export interface Command { - name: string; - content: string; - sourcePath: string; -} - -// --- Hook --- -export interface HookEntry { - type: string; - command?: string; - url?: string; -} - -export interface HookMatcher { - matcher?: string; - hooks: HookEntry[]; -} - -export interface Hooks { - [event: string]: HookMatcher[]; -} - -// --- Plugin Interface (Marketplace display metadata) --- -export interface PluginInterface { - displayName?: string; - shortDescription?: string; - longDescription?: string; - developerName?: string; - category?: string; - capabilities?: string[]; - websiteURL?: string; - privacyPolicyURL?: string; - termsOfServiceURL?: string; - defaultPrompt?: string[]; - brandColor?: string; - composerIcon?: string; - logo?: string; - screenshots?: string[]; -} - -// --- Plugin --- -export interface PluginMeta { - name: string; - description?: string; - version?: string; - author?: { name: string; email?: string; url?: string }; - source?: string; - category?: string; - displayName?: string; - homepage?: string; - repository?: string; - license?: string; - keywords?: string[]; - // Resource path overrides (from plugin.json) - skills?: string; - agents?: string; - commands?: string | string[]; - hooks?: string; - mcpServers?: string; - apps?: string; - // Marketplace display metadata - interface?: PluginInterface; -} - -// --- Marketplace --- -export interface MarketplaceMeta { - name: string; - version?: string; - description?: string; - owner?: { name: string; email?: string }; - metadata?: { description?: string; version?: string; pluginRoot?: string }; - plugins: MarketplacePluginEntry[]; -} - -export interface MarketplacePluginEntry { - name: string; - source: string; - description?: string; - version?: string; - category?: string; -} - -// --- Plugin Resource File --- -export interface PluginResourceFile { - relativePath: string; // relative to plugin root, e.g. "scripts/mcp-server/start.js" - content: string; -} - -// --- Scan Result --- -export interface ScanResult { - skills: Skill[]; - instructions: Instruction[]; - mcp: MCPConfig | null; - agents: Agent[]; - commands: Command[]; - hooks: Hooks | null; - pluginFiles: PluginResourceFile[]; // plugin-level resource files (scripts/, etc.) - rootDir: string; -} - -export interface PluginScanResult extends ScanResult { - meta: PluginMeta; -} - -export interface MarketplaceScanResult { - marketplace: MarketplaceMeta; - plugins: PluginScanResult[]; -} - -// --- Convert Result --- -export interface ConvertedFile { - path: string; - content: string; - type: 'skill' | 'instruction' | 'mcp' | 'agent' | 'command' | 'hook' | 'manifest' | 'resource'; -} - -export interface ConvertResult { - platform: Platform; - files: ConvertedFile[]; - warnings: string[]; -} diff --git a/src/utils/frontmatter.ts b/src/utils/frontmatter.ts deleted file mode 100644 index a3ff685..0000000 --- a/src/utils/frontmatter.ts +++ /dev/null @@ -1,22 +0,0 @@ -import matter from 'gray-matter'; - -export function parseFrontmatter(content: string): { data: T; body: string } { - const result = matter(content); - return { data: result.data as T, body: result.content }; -} - -export function stringifyFrontmatter(data: Record, body: string): string { - // Filter out undefined/null values - const cleanData: Record = {}; - for (const [key, value] of Object.entries(data)) { - if (value !== undefined && value !== null) { - cleanData[key] = value; - } - } - - if (Object.keys(cleanData).length === 0) { - return body; - } - - return matter.stringify(body, cleanData); -} diff --git a/src/utils/fs.ts b/src/utils/fs.ts deleted file mode 100644 index 60259e1..0000000 --- a/src/utils/fs.ts +++ /dev/null @@ -1,54 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -export function ensureDir(dirPath: string): void { - fs.mkdirSync(dirPath, { recursive: true }); -} - -export function writeFile(filePath: string, content: string): void { - ensureDir(path.dirname(filePath)); - fs.writeFileSync(filePath, content, 'utf-8'); -} - -export function readFile(filePath: string): string | null { - try { - return fs.readFileSync(filePath, 'utf-8'); - } catch { - return null; - } -} - -export function fileExists(filePath: string): boolean { - return fs.existsSync(filePath); -} - -export function listFiles(dir: string, pattern?: string): string[] { - if (!fs.existsSync(dir)) return []; - const entries = fs.readdirSync(dir, { withFileTypes: true, recursive: false }); - return entries - .filter(e => e.isFile() && (!pattern || e.name.match(new RegExp(pattern)))) - .map(e => path.join(dir, e.name)); -} - -export function listDirs(dir: string): string[] { - if (!fs.existsSync(dir)) return []; - const entries = fs.readdirSync(dir, { withFileTypes: true }); - return entries - .filter(e => e.isDirectory()) - .map(e => path.join(dir, e.name)); -} - -export function listFilesRecursive(dir: string): string[] { - if (!fs.existsSync(dir)) return []; - const results: string[] = []; - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isFile()) { - results.push(fullPath); - } else if (entry.isDirectory()) { - results.push(...listFilesRecursive(fullPath)); - } - } - return results; -} diff --git a/src/utils/model.ts b/src/utils/model.ts deleted file mode 100644 index 0851def..0000000 --- a/src/utils/model.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { Platform } from '../types.js'; - -// Codex default is gpt-5.6-sol; the lighter/faster tier (terra) suits fast -// small-model roles. See learn.chatgpt.com/docs/models. -const CODEX_DEFAULT_MODEL = 'gpt-5.6-sol'; -const CODEX_MODEL_MAP: Record = { - 'sonnet': 'gpt-5.6-sol', - 'opus': 'gpt-5.6-sol', - 'haiku': 'gpt-5.6-terra', - 'claude-sonnet-4-6': 'gpt-5.6-sol', - 'claude-opus-4-6': 'gpt-5.6-sol', - 'claude-haiku-4-5-20251001': 'gpt-5.6-terra', - 'inherit': 'gpt-5.6-sol', -}; - -// gemini-3-pro/gemini-3-flash are not valid API model IDs; gemini-3-pro-preview -// was discontinued 2026-03. See ai.google.dev/gemini-api/docs/models. -const ANTIGRAVITY_DEFAULT_MODEL = 'gemini-3.1-pro-preview'; -const ANTIGRAVITY_MODEL_MAP: Record = { - 'sonnet': 'gemini-3.1-pro-preview', - 'opus': 'gemini-3.1-pro-preview', - 'haiku': 'gemini-3.6-flash', - 'claude-sonnet-4-6': 'gemini-3.1-pro-preview', - 'claude-opus-4-6': 'gemini-3.1-pro-preview', - 'claude-haiku-4-5-20251001': 'gemini-3.6-flash', - 'inherit': 'gemini-3.1-pro-preview', -}; - -export function mapModel(model: string, platform: Platform): string { - switch (platform) { - case 'codex': - return CODEX_MODEL_MAP[model] || CODEX_DEFAULT_MODEL; - case 'antigravity': - return ANTIGRAVITY_MODEL_MAP[model] || ANTIGRAVITY_DEFAULT_MODEL; - case 'opencode': - return model; - case 'cursor': - return model; - case 'pi': - return model; - } -} diff --git a/src/utils/toml.ts b/src/utils/toml.ts deleted file mode 100644 index 74abe31..0000000 --- a/src/utils/toml.ts +++ /dev/null @@ -1,9 +0,0 @@ -import TOML from '@iarna/toml'; - -export function toToml(data: Record): string { - return TOML.stringify(data as any); -} - -export function parseToml(content: string): Record { - return TOML.parse(content) as Record; -} diff --git a/src/writer/antigravity.ts b/src/writer/antigravity.ts deleted file mode 100644 index 092832d..0000000 --- a/src/writer/antigravity.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { ScanResult, ConvertedFile, ConvertResult } from '../types.js'; -import { convertSkill, convertSkillAuxFiles } from '../converter/skill.js'; -import { mergeInstructions } from '../converter/instructions.js'; -import { convertMCP } from '../converter/mcp.js'; -import { convertAgent } from '../converter/agent.js'; -import { convertCommand } from '../converter/command.js'; -import { convertHooks } from '../converter/hooks.js'; - -export function generateAntigravity(scan: ScanResult): ConvertResult { - const files: ConvertedFile[] = []; - const warnings: string[] = []; - - // Skills → .agents/skills/ - for (const skill of scan.skills) { - files.push(convertSkill(skill, 'antigravity')); - files.push(...convertSkillAuxFiles(skill, 'antigravity')); - } - - // Instructions → GEMINI.md - files.push(...mergeInstructions(scan.instructions, 'antigravity')); - - // MCP → .agents/mcp_config.json - if (scan.mcp) { - files.push(convertMCP(scan.mcp, 'antigravity')); - } - - // Agents → .agents/agents/ - for (const agent of scan.agents) { - files.push(convertAgent(agent, 'antigravity')); - } - - // Commands → Skills - for (const cmd of scan.commands) { - files.push(convertCommand(cmd, 'antigravity')); - } - - // Hooks - if (scan.hooks) { - const hookResult = convertHooks(scan.hooks, 'antigravity'); - warnings.push(...hookResult.warnings); - } - - // Plugin-level resource files (scripts/, etc. referenced by MCP) - for (const pf of scan.pluginFiles) { - files.push({ path: pf.relativePath, content: pf.content, type: 'resource' }); - } - - return { - platform: 'antigravity', - files, - warnings, - }; -} diff --git a/src/writer/codex.ts b/src/writer/codex.ts deleted file mode 100644 index ead3603..0000000 --- a/src/writer/codex.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { ScanResult, ConvertedFile, ConvertResult, PluginScanResult } from '../types.js'; -import { convertSkill, convertSkillCodexYaml, convertSkillAuxFiles } from '../converter/skill.js'; -import { mergeInstructions } from '../converter/instructions.js'; -import { convertMCP } from '../converter/mcp.js'; -import { convertAgent } from '../converter/agent.js'; -import { convertCommand } from '../converter/command.js'; -import { convertHooks } from '../converter/hooks.js'; -import { convertPluginManifestForCodex } from '../converter/pluginManifest.js'; - -export function generateCodex(scan: ScanResult): ConvertResult { - const files: ConvertedFile[] = []; - const warnings: string[] = []; - - // Skills - for (const skill of scan.skills) { - files.push(convertSkill(skill, 'codex')); - files.push(...convertSkillAuxFiles(skill, 'codex')); - const yaml = convertSkillCodexYaml(skill); - if (yaml) files.push(yaml); - } - - // Instructions — merge all into one AGENTS.md - const instrFiles = mergeInstructions(scan.instructions, 'codex'); - files.push(...instrFiles); - - // MCP - if (scan.mcp) { - files.push(convertMCP(scan.mcp, 'codex')); - } - - // Agents — now generates .codex/agents/*.toml files - for (const agent of scan.agents) { - files.push(convertAgent(agent, 'codex')); - } - - // Commands → Skills - for (const cmd of scan.commands) { - files.push(convertCommand(cmd, 'codex')); - } - - // Hooks - if (scan.hooks) { - const hookResult = convertHooks(scan.hooks, 'codex'); - warnings.push(...hookResult.warnings); - - // Merge hook notes into AGENTS.md - if (hookResult.converted.length > 0) { - const hookContent = '\n\n---\n\n# Hooks (from Claude Code)\n\n' + - hookResult.converted.map(f => f.content).join('\n\n'); - const existingAgentsMd = files.find(f => f.path === 'AGENTS.md'); - if (existingAgentsMd) { - existingAgentsMd.content += hookContent; - } else { - files.push({ path: 'AGENTS.md', content: hookContent.trim(), type: 'hook' }); - } - } - } - - // Plugin-level resource files (scripts/, etc. referenced by MCP) - for (const pf of scan.pluginFiles) { - files.push({ path: pf.relativePath, content: pf.content, type: 'resource' }); - } - - // Generate .codex-plugin/plugin.json manifest - const meta = (scan as PluginScanResult).meta; - if (meta) { - files.push(convertPluginManifestForCodex(scan, meta)); - } - - return { - platform: 'codex', - files: files.filter(f => !f.path.includes('.hook-')), - warnings, - }; -} diff --git a/src/writer/cursor.ts b/src/writer/cursor.ts deleted file mode 100644 index d050800..0000000 --- a/src/writer/cursor.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { ScanResult, ConvertedFile, ConvertResult, PluginScanResult } from '../types.js'; -import { convertSkill, convertSkillAuxFiles } from '../converter/skill.js'; -import { mergeInstructions } from '../converter/instructions.js'; -import { convertMCP } from '../converter/mcp.js'; -import { convertAgent } from '../converter/agent.js'; -import { convertCommand } from '../converter/command.js'; -import { convertHooks } from '../converter/hooks.js'; -import { convertPluginManifestForCursor } from '../converter/pluginManifest.js'; - -export function generateCursor(scan: ScanResult): ConvertResult { - const files: ConvertedFile[] = []; - const warnings: string[] = []; - - // Skills - for (const skill of scan.skills) { - files.push(convertSkill(skill, 'cursor')); - files.push(...convertSkillAuxFiles(skill, 'cursor')); - } - - // Instructions - files.push(...mergeInstructions(scan.instructions, 'cursor')); - - // MCP - if (scan.mcp) { - files.push(convertMCP(scan.mcp, 'cursor')); - } - - // Agents - for (const agent of scan.agents) { - files.push(convertAgent(agent, 'cursor')); - } - - // Commands - for (const cmd of scan.commands) { - files.push(convertCommand(cmd, 'cursor')); - } - - // Hooks — generate Cursor-format hooks JSON - if (scan.hooks) { - const hookResult = convertHooks(scan.hooks, 'cursor'); - files.push(...hookResult.converted); - warnings.push(...hookResult.warnings); - } - - // Plugin-level resource files (scripts/, etc. referenced by MCP) - for (const pf of scan.pluginFiles) { - files.push({ path: pf.relativePath, content: pf.content, type: 'resource' }); - } - - // Cursor plugin manifest generation - const meta = (scan as PluginScanResult).meta; - files.push(convertPluginManifestForCursor(scan, meta)); - - // Remap paths: .cursor/xxx → plugin format (skills/, agents/, etc.) - for (const file of files) { - file.path = remapToPluginPath(file.path); - } - - return { platform: 'cursor', files, warnings }; -} - -/** - * Remap .cursor/ paths to plugin directory layout. - * .cursor/skills/X/SKILL.md → skills/X/SKILL.md - * .cursor/agents/X.md → agents/X.md - * .cursor/commands/X.md → commands/X.md - * .cursor/rules/X.mdc → rules/X.mdc - * .cursor/mcp.json → mcp.json - */ -function remapToPluginPath(filePath: string): string { - if (filePath.startsWith('.cursor/')) { - return filePath.slice('.cursor/'.length); - } - return filePath; -} diff --git a/src/writer/opencode.ts b/src/writer/opencode.ts deleted file mode 100644 index b996b67..0000000 --- a/src/writer/opencode.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { ScanResult, ConvertedFile, ConvertResult } from '../types.js'; -import { convertSkill, convertSkillAuxFiles } from '../converter/skill.js'; -import { mergeInstructions } from '../converter/instructions.js'; -import { convertMCP } from '../converter/mcp.js'; -import { convertAgent } from '../converter/agent.js'; -import { convertCommand } from '../converter/command.js'; -import { convertHooks } from '../converter/hooks.js'; - -export function generateOpenCode(scan: ScanResult): ConvertResult { - const files: ConvertedFile[] = []; - const warnings: string[] = []; - - // Skills - for (const skill of scan.skills) { - files.push(convertSkill(skill, 'opencode')); - files.push(...convertSkillAuxFiles(skill, 'opencode')); - } - - // Instructions - files.push(...mergeInstructions(scan.instructions, 'opencode')); - - // MCP - if (scan.mcp) { - files.push(convertMCP(scan.mcp, 'opencode')); - } - - // Agents - for (const agent of scan.agents) { - files.push(convertAgent(agent, 'opencode')); - } - - // Commands - for (const cmd of scan.commands) { - files.push(convertCommand(cmd, 'opencode')); - } - - // Hooks - if (scan.hooks) { - const hookResult = convertHooks(scan.hooks, 'opencode'); - warnings.push(...hookResult.warnings); - - if (hookResult.converted.length > 0) { - const hookContent = '\n\n---\n\n# Hooks (from Claude Code)\n\n' + - hookResult.converted.map(f => f.content).join('\n\n'); - const existingAgentsMd = files.find(f => f.path === 'AGENTS.md'); - if (existingAgentsMd) { - existingAgentsMd.content += hookContent; - } else { - files.push({ path: 'AGENTS.md', content: hookContent.trim(), type: 'hook' }); - } - } - } - - // Plugin-level resource files (scripts/, etc. referenced by MCP) - for (const pf of scan.pluginFiles) { - files.push({ path: pf.relativePath, content: pf.content, type: 'resource' }); - } - - return { - platform: 'opencode', - files: files.filter(f => !f.path.includes('.hook-')), - warnings, - }; -} diff --git a/src/writer/pi.ts b/src/writer/pi.ts deleted file mode 100644 index 71b8ac8..0000000 --- a/src/writer/pi.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { ScanResult, ConvertedFile, ConvertResult } from '../types.js'; -import { convertSkill, convertSkillAuxFiles } from '../converter/skill.js'; -import { mergeInstructions } from '../converter/instructions.js'; -import { convertCommand } from '../converter/command.js'; - -/** - * Generate output for Pi (pi-coding-agent, earendil-works/pi). - * - * Pi is a minimal terminal harness whose only file-based extension formats are - * Claude-style Agent Skills (SKILL.md) and instruction files (AGENTS.md). It - * has no subagent, hooks, or MCP format by design — those are handled by - * writing TypeScript extensions, which we cannot generate. Commands have no - * native format either, so we degrade them to prompt templates. - */ -export function generatePi(scan: ScanResult): ConvertResult { - const files: ConvertedFile[] = []; - const warnings: string[] = []; - - // Skills → .pi/skills//SKILL.md (Claude-style, near-identical format) - for (const skill of scan.skills) { - files.push(convertSkill(skill, 'pi')); - files.push(...convertSkillAuxFiles(skill, 'pi')); - } - - // Instructions → AGENTS.md - files.push(...mergeInstructions(scan.instructions, 'pi')); - - // Commands → prompt templates (.pi/prompts/.md), exposed as /name - for (const cmd of scan.commands) { - files.push(convertCommand(cmd, 'pi')); - } - - // MCP: Pi does not support MCP (and states it never will). - if (scan.mcp && scan.mcp.servers.length > 0) { - warnings.push( - `Pi does not support MCP — ${scan.mcp.servers.length} server(s) skipped. ` + - `Wrap them as a CLI tool or a Pi TypeScript extension instead.`, - ); - } - - // Agents: Pi intentionally has no subagent format. - if (scan.agents.length > 0) { - warnings.push( - `Pi has no subagent format — ${scan.agents.length} agent(s) skipped. ` + - `Pi expects agents to be composed via bash/tmux or a TypeScript extension.`, - ); - } - - // Hooks: Pi handles lifecycle events only through TypeScript extensions. - if (scan.hooks && Object.keys(scan.hooks).length > 0) { - warnings.push( - `Pi has no file-based hooks format — ${Object.keys(scan.hooks).length} hook event(s) skipped. ` + - `Reimplement them as a Pi TypeScript extension (pi.on(...)).`, - ); - } - - // Plugin-level resource files (scripts/, etc.) are still copied through. - for (const pf of scan.pluginFiles) { - files.push({ path: pf.relativePath, content: pf.content, type: 'resource' }); - } - - return { platform: 'pi', files, warnings }; -} diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..8547306 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "paths": { + "@acplugin/core": ["./packages/core/src/index.ts"], + "@acplugin/core/author": ["./packages/core/src/api/author.ts"], + "@acplugin/core/integration": ["./packages/core/src/api/integration.ts"], + "@tokenroll/acplugin": ["./packages/acplugin/src/index.ts"], + "@tokenroll/acplugin/sdk": ["./packages/acplugin/src/sdk.ts"], + "@tokenroll/acplugin-platform-antigravity": ["./packages/platforms/antigravity/src/index.ts"], + "@tokenroll/acplugin-platform-claude-code": ["./packages/platforms/claude-code/src/index.ts"], + "@tokenroll/acplugin-platform-codex": ["./packages/platforms/codex/src/index.ts"], + "@tokenroll/acplugin-platform-cursor": ["./packages/platforms/cursor/src/index.ts"], + "@tokenroll/acplugin-platform-opencode": ["./packages/platforms/opencode/src/index.ts"], + "@tokenroll/acplugin-platform-pi": ["./packages/platforms/pi/src/index.ts"], + "@tokenroll/acplugin-extension-hooks": ["./packages/extensions/hooks/src/index.ts"], + "@tokenroll/acplugin-extension-mcp": ["./packages/extensions/mcp/src/index.ts"], + }, + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "noEmit": true + } +} diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index 8d4e7cc..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "Node16", - "moduleResolution": "Node16", - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "declaration": true - }, - "include": ["src/**/*"] -} diff --git a/vitest.config.ts b/vitest.config.ts deleted file mode 100644 index 7382f40..0000000 --- a/vitest.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - globals: true, - }, -});