Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff5af6a7cf | ||
|
|
3f17e75b4b | ||
|
|
7cb6c30039 | ||
|
|
9544c80eba | ||
|
|
f45cb4b92a | ||
|
|
bc6eb789ce | ||
|
|
9de80b4deb | ||
|
|
a85bfed8dc | ||
|
|
b7dabdbc4f | ||
|
|
9d22eea7d2 | ||
|
|
45bae55787 | ||
|
|
987cb3b96b | ||
|
|
961f410094 | ||
|
|
802a1b0b75 | ||
|
|
42ce2c75ab | ||
|
|
3206274e0b | ||
|
|
3457e65be5 | ||
|
|
11404c05f4 | ||
|
|
a74d0502b9 | ||
|
|
351ad8282a | ||
|
|
07680f66e2 | ||
|
|
eaa9fe5cd5 | ||
|
|
9d658d767a | ||
|
|
22de1ff010 | ||
|
|
2fb79a99c8 | ||
|
|
2da5e86289 | ||
|
|
6ebae721f6 | ||
|
|
677997bd59 | ||
|
|
b22d5245b1 | ||
|
|
5806c94008 | ||
|
|
c224ce482a | ||
|
|
102661ca6f | ||
|
|
2f134aaa7e | ||
|
|
e8e265a0bc | ||
|
|
d3359ebef0 | ||
|
|
2aea370e1c | ||
|
|
a34e48b35b | ||
|
|
d206a19c7d | ||
|
|
15a5e08338 | ||
|
|
4c945f570d | ||
|
|
6649e8e7ba |
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "rea",
|
||||
"interface": {
|
||||
"displayName": "REA"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "rea",
|
||||
"source": {
|
||||
"source": "npm",
|
||||
"package": "rea-agents",
|
||||
"version": "6.4.0"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "Developer Tools"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -17,6 +17,9 @@ readiness or provider commands.
|
||||
|
||||
Installing this skill supplies instructions; it does not register the REA MCP
|
||||
server, install analysis engines, or add tools to an already-running agent.
|
||||
The exception is a Claude Code or Codex plugin install: the plugin bundles
|
||||
this skill and registers its own `rea` server, outside the state that
|
||||
`rea setup` and `rea doctor` manage.
|
||||
|
||||
If REA tools are available and their registration is not known to be stale,
|
||||
proceed directly to the target. Do not run diagnostics or setup before every
|
||||
|
||||
@@ -13,7 +13,7 @@ main and an older release expose the same arguments.
|
||||
|
||||
| Observation | Meaning and next step |
|
||||
| --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| No REA tools in the active session | Inspect registration with `rea doctor --client codex --json`. A skill installation alone does not register MCP. |
|
||||
| No REA tools in the active session | If REA was installed as a client plugin, follow the plugin section below. Otherwise inspect registration with `rea doctor --client codex --json`. A skill installation alone does not register MCP. |
|
||||
| Missing, malformed or stale registration | Prepare a scoped setup plan; apply only authorized changes with backups. |
|
||||
| Aligned registration, but no connected tools | Restart/reconnect the client. Doctor checks registration; it cannot establish the current agent's connection. Inspect the client's launch error if reconnection fails. |
|
||||
| `MCP tool call requires approval, but approval policy is never` | Codex rejected the call before REA executed it. Do not install an engine or repeat setup to solve this client-policy decision. See the approval section below. |
|
||||
@@ -34,6 +34,33 @@ answers part of the question, and name the unavailable evidence separately.
|
||||
Do not substitute another target. Label observations from ordinary shell tools
|
||||
as such; only actual REA output supplies REA Evidence.
|
||||
|
||||
## REA installed as a client plugin
|
||||
|
||||
A Claude Code or Codex plugin owns its `rea` server and this skill. Doctor's
|
||||
`--client` and `--skill` checks and `rea setup` read and write only
|
||||
setup-owned state, so for a plugin-only install they report `missing`, and
|
||||
applying setup would add a second `rea` server. If this skill was loaded from
|
||||
a plugin, or the client lists a `rea` plugin, do not prepare a setup plan for
|
||||
missing tools. Instead:
|
||||
|
||||
- Check that the plugin is installed and enabled with the client's own
|
||||
listing: `claude plugin list` and `/mcp` in Claude Code, `codex plugin list`
|
||||
in Codex.
|
||||
- Restart or reconnect the client, then read its launch error for the `rea`
|
||||
server if tools are still absent. The first start downloads the pinned
|
||||
package through `npx` and can exceed the client's startup timeout; a second
|
||||
start uses the cache.
|
||||
- Check analysis engines through the connected server's
|
||||
`binary_session.result.tool_availability`. `rea doctor --provider <id> --json`
|
||||
from a shell reports that shell's environment, which can differ from the
|
||||
environment the client gave the plugin server. If you still run it through
|
||||
the package runner, use the version pinned in the plugin manifest's server
|
||||
entry (`rea-agents@<version>`), not `@latest`, so it matches the server the
|
||||
plugin starts.
|
||||
|
||||
Use the setup repair below only when the user wants a setup-owned registration
|
||||
instead of the plugin, and remove or disable the plugin first.
|
||||
|
||||
## Repair registration when needed
|
||||
|
||||
For Codex:
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
|
||||
"name": "rea",
|
||||
"description": "Reverse engineer anything with agents, from app behavior down to native binaries.",
|
||||
"owner": {
|
||||
"name": "morluto",
|
||||
"url": "https://github.com/morluto"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "rea",
|
||||
"description": "Reverse engineer anything from your terminal or agent with one CLI and MCP server.",
|
||||
"source": {
|
||||
"source": "npm",
|
||||
"package": "rea-agents",
|
||||
"version": "6.4.0"
|
||||
},
|
||||
"category": "development"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "rea",
|
||||
"version": "6.4.0",
|
||||
"description": "Reverse engineer anything from your terminal or agent with one CLI and MCP server.",
|
||||
"author": {
|
||||
"name": "morluto",
|
||||
"url": "https://github.com/morluto"
|
||||
},
|
||||
"homepage": "https://rea.tools/",
|
||||
"repository": "https://github.com/morluto/rea",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"reverse-engineering",
|
||||
"binary-analysis",
|
||||
"decompiler",
|
||||
"mcp-server"
|
||||
],
|
||||
"skills": "./skills",
|
||||
"mcpServers": {
|
||||
"rea": {
|
||||
"command": "node",
|
||||
"args": [
|
||||
"${CLAUDE_PLUGIN_ROOT}/scripts/plugin-mcp-launcher.mjs",
|
||||
"6.4.0"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "rea",
|
||||
"version": "6.4.0",
|
||||
"description": "Reverse engineer anything from your terminal or agent with one CLI and MCP server.",
|
||||
"author": {
|
||||
"name": "morluto",
|
||||
"url": "https://github.com/morluto"
|
||||
},
|
||||
"homepage": "https://rea.tools/",
|
||||
"repository": "https://github.com/morluto/rea",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"reverse-engineering",
|
||||
"binary-analysis",
|
||||
"decompiler",
|
||||
"mcp-server"
|
||||
],
|
||||
"skills": "./skills",
|
||||
"mcpServers": "./.mcp.json",
|
||||
"interface": {
|
||||
"displayName": "REA",
|
||||
"shortDescription": "Reverse engineer anything with agents",
|
||||
"longDescription": "Use REA to explain shipped behavior from native binaries, managed assemblies, Electron and JavaScript bundles, packages, firmware, and browser targets, with evidence that separates observations from inferences.",
|
||||
"developerName": "morluto",
|
||||
"category": "Developer Tools",
|
||||
"capabilities": [
|
||||
"Interactive",
|
||||
"Read",
|
||||
"Write"
|
||||
],
|
||||
"websiteURL": "https://rea.tools/",
|
||||
"defaultPrompt": [
|
||||
"Use REA to explain how this binary implements a feature.",
|
||||
"Use REA to compare two versions of this application.",
|
||||
"Use REA to recover the sources from this JavaScript bundle."
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ runs:
|
||||
shell: bash
|
||||
env:
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
LOCKFILES_HASH: ${{ hashFiles('package-lock.json', 'npm-shrinkwrap.json', '.npmrc') }}
|
||||
LOCKFILES_HASH: ${{ hashFiles('npm-shrinkwrap.json', '.npmrc') }}
|
||||
run: |
|
||||
cache_path="$(npm config get cache)"
|
||||
runtime_arch="$(node -p 'process.arch')"
|
||||
|
||||
@@ -21,7 +21,7 @@ runs:
|
||||
if: inputs.build_cache_artifact == ''
|
||||
shell: bash
|
||||
env:
|
||||
BUILD_CACHE_PREFIX: rea-portable-build-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.npmrc', '.nvmrc', 'package-lock.json') }}
|
||||
BUILD_CACHE_PREFIX: rea-portable-build-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.npmrc', '.nvmrc', 'npm-shrinkwrap.json') }}
|
||||
run: |
|
||||
npm run --silent test:prepare -- --dry=json |
|
||||
node --input-type=module -e '
|
||||
|
||||
@@ -133,9 +133,9 @@ jobs:
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: .turbo/cache/
|
||||
key: rea-static-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc', '.npmrc', 'package-lock.json') }}-${{ github.sha }}
|
||||
key: rea-static-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc', '.npmrc', 'npm-shrinkwrap.json') }}-${{ github.sha }}
|
||||
# Turbo validates each task's inputs after restoring this snapshot.
|
||||
restore-keys: rea-static-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc', '.npmrc', 'package-lock.json') }}-
|
||||
restore-keys: rea-static-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc', '.npmrc', 'npm-shrinkwrap.json') }}-
|
||||
- name: Validate workflow syntax and expressions
|
||||
if: needs.changes.outputs.workflows == 'true'
|
||||
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 -shellcheck= -pyflakes=
|
||||
|
||||
@@ -25,7 +25,7 @@ on:
|
||||
- "scripts/lib/mcp-verifier-results.mjs"
|
||||
- "scripts/rea.mjs"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "npm-shrinkwrap.json"
|
||||
- ".nvmrc"
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"rea": {
|
||||
"command": "node",
|
||||
"args": ["./scripts/plugin-mcp-launcher.mjs", "6.4.0"],
|
||||
"cwd": ".",
|
||||
"startup_timeout_sec": 30
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -9,7 +9,9 @@
|
||||
"docs/api/**",
|
||||
"node_modules/**",
|
||||
"CHANGELOG.md",
|
||||
"package-lock.json",
|
||||
"npm-shrinkwrap.json",
|
||||
".claude-plugin/plugin.json",
|
||||
".codex-plugin/plugin.json",
|
||||
"_reference/**",
|
||||
"third_party/**"
|
||||
]
|
||||
|
||||
@@ -131,6 +131,9 @@ npm ci
|
||||
npm run check:fast
|
||||
```
|
||||
|
||||
`npm-shrinkwrap.json` is the sole dependency lock for development and the
|
||||
published plugins. Do not add a root `package-lock.json`; npm would ignore it.
|
||||
|
||||
`npm ci` installs the exact dependencies and prepares the Husky hooks without
|
||||
building the project. Run `npm run build:cached` when you need the standalone
|
||||
CLI or MCP server; it compiles the runtime and bundles the authored skill.
|
||||
|
||||
@@ -15,7 +15,7 @@ notices supplied with the software you actually obtain or redistribute.
|
||||
|
||||
| Material | Use and licensing records |
|
||||
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| npm dependencies | [Complete inventory](licenses/npm-dependencies.txt) of every entry in `package-lock.json`, including transitive, optional platform, development, and website dependencies. [Runtime notices](licenses/npm-runtime-notices.txt) preserve the copyright, license, and NOTICE documents from integrity-verified release archives, plus the MIT notice for Rolldown's generated runtime helpers. Development dependencies are inventoried but are not all distributed in REA's npm package. |
|
||||
| npm dependencies | [Complete inventory](licenses/npm-dependencies.txt) of every entry in `npm-shrinkwrap.json`, including transitive, optional platform, development, and website dependencies. [Runtime notices](licenses/npm-runtime-notices.txt) preserve the copyright, license, and NOTICE documents from integrity-verified release archives, plus the MIT notice for Rolldown's generated runtime helpers. Development dependencies are inventoried but are not all distributed in REA's npm package. |
|
||||
| NativeAOT parser | REA's derived parser uses the MIT-licensed [ghidra-nativeaot](https://github.com/Washi1337/ghidra-nativeaot/tree/effeb734fc570c32650f88b159608979dc7b423e) work of Washi and the NativeAOT layout definitions in [dotnet/runtime](https://github.com/dotnet/runtime/tree/a3b2d40328be0be3f8dd43f0e5cc925b8827b044). Preserve [Washi's MIT notice](licenses/nativeaot-parser-MIT.txt) and the [.NET Foundation MIT notice](licenses/dotnet-runtime-MIT.txt). |
|
||||
| EVM analysis | `evmole@0.9.3`, MIT, copyright 2023 Maxim Andreev. The published npm archive omits its license document; REA retains the [original upstream license](third_party/evmole/LICENSE) and [provenance](third_party/evmole/README.md). It is also included in the runtime notices. |
|
||||
| Windows native addon | The release addon is compiled with MinGW-w64 and statically links GCC runtime libraries. Preserve the [MinGW-w64 runtime notices](licenses/MinGW-w64-runtime.txt), [GPLv3 text](licenses/GPL-3.0.txt), and [GCC Runtime Library Exception 3.1](licenses/GCC-Runtime-Library-Exception-3.1.txt). The exception governs eligible compiled combinations; it does not relicense the runtime itself under MIT. The Node-API build headers are from Node 22.19.0; its [original license and component notices](licenses/Node-22.19.0-LICENSE.txt) are retained. Node itself is supplied by the user. Snapshot provenance and release-toolchain review requirements are in [licenses/README.md](licenses/README.md). |
|
||||
|
||||
@@ -805,6 +805,84 @@ read-only operations, digest identity, and runtime cleanup. The separate
|
||||
ordinary user. It requires the built Windows native bundle and an existing
|
||||
Ghidra/JDK installation; neither is inferred from startup alone.
|
||||
|
||||
## Agent plugins and marketplaces
|
||||
|
||||
REA's npm package is the shared Claude Code and Codex plugin artifact. It
|
||||
contains `.claude-plugin/plugin.json`, `.codex-plugin/plugin.json`, and the
|
||||
canonical generated `skills/` tree. Both manifests select `./skills` and run a plain Node bootstrap pinned to
|
||||
that exact package version. The client must have a supported standalone `node`
|
||||
on its PATH. Claude expands its documented `CLAUDE_PLUGIN_ROOT` in the script
|
||||
argument; Codex resolves `cwd: "."` from the installed plugin root before
|
||||
launching the relative script. The shared bootstrap preserves the pinned
|
||||
package-runner arguments and stdio, handles Windows npm shims, and uses an owned
|
||||
empty working directory so an unbuilt local checkout cannot shadow the exact
|
||||
registry release. The existing release skill
|
||||
generator also pins CLI recovery commands to the same version.
|
||||
|
||||
The repository's single-plugin marketplaces are declared in
|
||||
`.claude-plugin/marketplace.json` and `.agents/plugins/marketplace.json`.
|
||||
Both select an exact `rea-agents` npm version instead of mutable source-checkout
|
||||
contents. Release Please updates their selectors alongside the plugin manifests.
|
||||
No plugin-only publication or generated skill files committed to Git are needed.
|
||||
|
||||
This distribution becomes installable after the first qualified npm release
|
||||
containing these plugin manifests. Earlier published packages are not changed
|
||||
by this source proposal. For local source development, run `npm ci && npm run build:cached` to generate
|
||||
the runtime and packaged skill before loading the repository as a plugin.
|
||||
On Windows, also build the Windows x64 native addon following the
|
||||
[Windows source-checkout guide](https://github.com/morluto/rea/blob/main/native/windows/README.md#build);
|
||||
the registry fallback requires that addon and the built process supervisor to
|
||||
own and clean up the package-runner process tree. The source plugin always
|
||||
launches its pinned registry release, even when the checkout has been built. The authored
|
||||
`.agents/skills/` tree is not the plugin's shipped skill layout.
|
||||
|
||||
In Claude Code:
|
||||
|
||||
```text
|
||||
/plugin marketplace add morluto/rea
|
||||
/plugin install rea@rea
|
||||
```
|
||||
|
||||
In Codex, add the marketplace and then install REA from the plugin list:
|
||||
|
||||
```bash
|
||||
codex plugin marketplace add morluto/rea
|
||||
```
|
||||
|
||||
Each release updates both manifest versions and their pinned server version
|
||||
together, which is when installed copies refresh the bundled skill and move to
|
||||
the new server. A plugin does not run `rea setup`, so provider
|
||||
configuration still follows the sections above. Use the plugin or a
|
||||
`rea setup` registration for a client, not both, to avoid two `rea` servers.
|
||||
|
||||
`rea doctor` does not see plugin-owned state. Its client and skill checks read
|
||||
only the registrations and skill locations that `rea setup` manages, so for a
|
||||
plugin-only install `rea doctor --client <id>` and `rea doctor --skill` report
|
||||
`missing` and recommend `rea setup`. Do not follow that remediation. Check the
|
||||
plugin with the client instead (`claude plugin list` and `/mcp` in Claude
|
||||
Code, `codex plugin list` in Codex).
|
||||
|
||||
For analysis engines, ask the connected server: have the agent call
|
||||
`binary_session` and read `result.tool_availability`, which reports what the
|
||||
server the plugin started can use, with reasons and remediation. See
|
||||
[MCP contracts](mcp-contracts.md).
|
||||
|
||||
A plugin does not put `rea` on your `PATH`. To check an engine from a
|
||||
terminal, use the package runner at the version the plugin pins:
|
||||
|
||||
<!-- x-release-please-start-version -->
|
||||
|
||||
```bash
|
||||
npx -y rea-agents@6.4.0 doctor --provider ghidra --json
|
||||
```
|
||||
|
||||
<!-- x-release-please-end -->
|
||||
|
||||
If your installed plugin is older, use the version in its manifest instead.
|
||||
That report describes the terminal's environment, not the plugin server's.
|
||||
The server inherits the client's environment, so a setting such as
|
||||
`GHIDRA_INSTALL_DIR` that exists only in your terminal does not reach it.
|
||||
|
||||
## MCP Registry
|
||||
|
||||
REA is published in the official MCP Registry as `io.github.morluto/rea`. Registry
|
||||
|
||||
+5
-2
@@ -240,8 +240,11 @@ Review and test this synchronization PR, then use a merge commit so the release
|
||||
tag remains in main's ancestry. Keep the released tag unchanged.
|
||||
|
||||
The synchronization must include `.release-please-manifest.json`,
|
||||
`package.json`, both root versions in `package-lock.json`, `CHANGELOG.md`,
|
||||
`server.json`, and the versioned documentation examples. Run
|
||||
`package.json`, both root versions in `npm-shrinkwrap.json`, `CHANGELOG.md`,
|
||||
`server.json`, both plugin manifests (`.claude-plugin/plugin.json` and
|
||||
`.codex-plugin/plugin.json`), the Codex server declaration (`.mcp.json`), both
|
||||
marketplaces (`.claude-plugin/marketplace.json` and
|
||||
`.agents/plugins/marketplace.json`), and the versioned documentation examples. Run
|
||||
`npm run docs:generate`, `npm run docs:check`, and the release configuration
|
||||
tests against the combined tree. After the merge, verify that the released
|
||||
tag is an ancestor of main:
|
||||
|
||||
@@ -111,7 +111,7 @@ or acquire that reduction automatically.
|
||||
|
||||
Use the repository's pinned Node/npm and locked dependencies. Build each selected
|
||||
revision, then copy its `dist/`, `bridge/`, `scripts/`, `skills/`, `package.json`
|
||||
and `package-lock.json` to a separate runtime directory before editing production
|
||||
and `npm-shrinkwrap.json` to a separate runtime directory before editing production
|
||||
sources. That copy needs the matching locked dependencies. The initial frozen
|
||||
baseline is `42b0ed126df7d5b908ac369715378d5e519051b7`; this study does not
|
||||
silently substitute a newer `main` or the previous Code Mode experiment.
|
||||
|
||||
@@ -216,7 +216,7 @@ def main():
|
||||
'authority': args.authority,
|
||||
'runtime_files_sha256': runtime_digest,
|
||||
'runtime_dispatcher_sha256': file_digest(repository / 'scripts/rea.mjs'),
|
||||
'package_lock_sha256': file_digest(repository / 'package-lock.json'),
|
||||
'package_lock_sha256': file_digest(repository / 'npm-shrinkwrap.json'),
|
||||
'experiment_sources_sha256': {
|
||||
name: hashlib.sha256((experiment / name).read_bytes()).hexdigest()
|
||||
for name in ['agent-trial.py', 'codex_context.py', 'provider-environment.json']
|
||||
|
||||
@@ -147,7 +147,7 @@ const fingerprints = {
|
||||
resolve(values["runtime-root"], "scripts/rea.mjs"),
|
||||
),
|
||||
package_lock_sha256: await fileSha(
|
||||
resolve(values["runtime-root"], "package-lock.json"),
|
||||
resolve(values["runtime-root"], "npm-shrinkwrap.json"),
|
||||
),
|
||||
};
|
||||
const clientVersion = JSON.parse(
|
||||
|
||||
@@ -109,7 +109,7 @@ class ContextCaptureTests(unittest.TestCase):
|
||||
runtime = root / 'runtime'
|
||||
(runtime / 'scripts').mkdir(parents=True)
|
||||
(runtime / 'scripts' / 'rea.mjs').write_text('')
|
||||
(runtime / 'package-lock.json').write_text('{}')
|
||||
(runtime / 'npm-shrinkwrap.json').write_text('{}')
|
||||
auth_home = root / 'source-auth'
|
||||
auth_home.mkdir()
|
||||
(auth_home / 'auth.json').write_text('synthetic-credential')
|
||||
|
||||
+1007
-117
File diff suppressed because it is too large
Load Diff
Generated
+2333
-197
File diff suppressed because it is too large
Load Diff
+6
-5
@@ -47,6 +47,7 @@
|
||||
"packageManager": "npm@11.16.0",
|
||||
"files": [
|
||||
"dist",
|
||||
"npm-shrinkwrap.json",
|
||||
"install.sh",
|
||||
"bridge/hopper_bridge.py",
|
||||
"bridge/cutter/rea_bridge.py",
|
||||
@@ -67,12 +68,16 @@
|
||||
"bridge/android/ReaJadxBridge.java",
|
||||
"bridge/mitmproxy/capture.py",
|
||||
"scripts/rea.mjs",
|
||||
"scripts/plugin-mcp-launcher.mjs",
|
||||
"scripts/rea-android-workspace-guardian.mjs",
|
||||
"scripts/electron-active-hook.cjs",
|
||||
"scripts/electron-active-hook-boundaries.cjs",
|
||||
"scripts/hopper-demo-x11.py",
|
||||
"scripts/prepack.mjs",
|
||||
"scripts/prepare.mjs",
|
||||
".claude-plugin/plugin.json",
|
||||
".codex-plugin/plugin.json",
|
||||
".mcp.json",
|
||||
"skills",
|
||||
"README.md",
|
||||
"DISCLAIMER.md",
|
||||
@@ -351,11 +356,6 @@
|
||||
"yaml": "2.9.0",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"overrides": {
|
||||
"vitepress": {
|
||||
"vite": "6.4.4"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fast-check/vitest": "0.5.0",
|
||||
"@stryker-mutator/core": "10.0.0",
|
||||
@@ -373,6 +373,7 @@
|
||||
"lint-staged": "16.4.0",
|
||||
"oxfmt": "0.72.0",
|
||||
"oxlint": "1.87.0",
|
||||
"release-please": "17.3.0",
|
||||
"rolldown": "1.2.12",
|
||||
"turbo": "2.11.7",
|
||||
"typescript": "7.0.2",
|
||||
|
||||
@@ -31,6 +31,36 @@
|
||||
"type": "json",
|
||||
"path": "server.json",
|
||||
"jsonpath": "$.packages[0].version"
|
||||
},
|
||||
{
|
||||
"type": "json",
|
||||
"path": ".claude-plugin/plugin.json",
|
||||
"jsonpath": "$.version"
|
||||
},
|
||||
{
|
||||
"type": "json",
|
||||
"path": ".claude-plugin/plugin.json",
|
||||
"jsonpath": "$.mcpServers.rea.args[1]"
|
||||
},
|
||||
{
|
||||
"type": "json",
|
||||
"path": ".codex-plugin/plugin.json",
|
||||
"jsonpath": "$.version"
|
||||
},
|
||||
{
|
||||
"type": "json",
|
||||
"path": ".mcp.json",
|
||||
"jsonpath": "$.mcpServers.rea.args[1]"
|
||||
},
|
||||
{
|
||||
"type": "json",
|
||||
"path": ".claude-plugin/marketplace.json",
|
||||
"jsonpath": "$.plugins[0].source.version"
|
||||
},
|
||||
{
|
||||
"type": "json",
|
||||
"path": ".agents/plugins/marketplace.json",
|
||||
"jsonpath": "$.plugins[0].source.version"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ const installedPackageKey = (name) => `node_modules/${name}`;
|
||||
export const dependencyInstallProblems = (rootLock, installedLock) => {
|
||||
const rootPackage = rootLock.packages?.[""];
|
||||
if (rootPackage === undefined)
|
||||
return ["package-lock.json does not contain a root package entry"];
|
||||
return ["npm-shrinkwrap.json does not contain a root package entry"];
|
||||
const installedPackages = installedLock.packages ?? {};
|
||||
return Object.keys(directDependencies(rootPackage))
|
||||
.sort()
|
||||
@@ -26,7 +26,7 @@ export const dependencyInstallProblems = (rootLock, installedLock) => {
|
||||
const expected = rootLock.packages?.[key]?.version;
|
||||
const actual = installedPackages[key]?.version;
|
||||
if (expected === undefined)
|
||||
return [`${name}: missing resolved version in package-lock.json`];
|
||||
return [`${name}: missing resolved version in npm-shrinkwrap.json`];
|
||||
if (actual === undefined)
|
||||
return [`${name}: missing from the installed dependency lockfile`];
|
||||
return actual === expected
|
||||
@@ -39,7 +39,7 @@ const main = async () => {
|
||||
const root = process.cwd();
|
||||
try {
|
||||
const [rootLock, installedLock] = await Promise.all([
|
||||
readJson(resolve(root, "package-lock.json")),
|
||||
readJson(resolve(root, "npm-shrinkwrap.json")),
|
||||
readJson(resolve(root, "node_modules/.package-lock.json")),
|
||||
]);
|
||||
const problems = dependencyInstallProblems(rootLock, installedLock);
|
||||
|
||||
@@ -14,7 +14,7 @@ const packageJson = JSON.parse(
|
||||
await readFile(join(root, "package.json"), "utf8"),
|
||||
);
|
||||
const lock = JSON.parse(
|
||||
await readFile(join(root, "package-lock.json"), "utf8"),
|
||||
await readFile(join(root, "npm-shrinkwrap.json"), "utf8"),
|
||||
);
|
||||
const skill = await readFile(
|
||||
join(root, ".agents/skills/reverse-engineer-anything/SKILL.md"),
|
||||
@@ -29,7 +29,7 @@ const versionOf = (name) => {
|
||||
throw new Error(`Missing locked ${name} version`);
|
||||
return value;
|
||||
};
|
||||
const source = `/** Generated from package.json and package-lock.json; do not edit. */
|
||||
const source = `/** Generated from package.json and npm-shrinkwrap.json; do not edit. */
|
||||
export const PACKAGE_METADATA = {
|
||||
name: ${JSON.stringify(packageJson.name)},
|
||||
version: ${JSON.stringify(packageJson.version)}, // x-release-please-version
|
||||
|
||||
@@ -11,7 +11,7 @@ for (const argument of arguments_)
|
||||
throw new Error(`Unknown inventory option: ${argument}`);
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const lock = JSON.parse(
|
||||
await readFile(join(root, "package-lock.json"), "utf8"),
|
||||
await readFile(join(root, "npm-shrinkwrap.json"), "utf8"),
|
||||
);
|
||||
const entries = Object.entries(lock.packages)
|
||||
.filter(([path]) => path !== "")
|
||||
@@ -38,7 +38,7 @@ const runtimeFingerprint = createHash("sha256")
|
||||
.digest("hex");
|
||||
const source = [
|
||||
"REA npm dependency inventory",
|
||||
"Prepared from the root package-lock.json; refresh this inventory when locked dependencies change.",
|
||||
"Prepared from the root npm-shrinkwrap.json; refresh this inventory when locked dependencies change.",
|
||||
"License identifiers are upstream lockfile declarations, not a legal assessment.",
|
||||
"Runtime includes transitive and optional platform packages; development includes build, test and website tooling.",
|
||||
`Locked package entries: ${entries.length}; runtime: ${runtime.length}.`,
|
||||
|
||||
@@ -4,6 +4,37 @@ import { z } from "zod";
|
||||
const shaSchema = z.string().regex(/^[a-f0-9]{40}$/u);
|
||||
const versionSchema = z.object({ version: z.string() });
|
||||
const manifestSchema = z.object({ ".": z.string() });
|
||||
const PLUGIN_MANIFEST_PATHS = [
|
||||
".claude-plugin/plugin.json",
|
||||
".codex-plugin/plugin.json",
|
||||
];
|
||||
const inlinePluginManifestSchema = versionSchema.extend({
|
||||
mcpServers: z.object({
|
||||
rea: z.object({
|
||||
command: z.literal("node"),
|
||||
args: z.tuple([
|
||||
z.literal("${CLAUDE_PLUGIN_ROOT}/scripts/plugin-mcp-launcher.mjs"),
|
||||
z.string(),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const codexPluginManifestSchema = versionSchema.extend({
|
||||
mcpServers: z.literal("./.mcp.json"),
|
||||
});
|
||||
const codexServerConfigSchema = z.object({
|
||||
mcpServers: z.object({
|
||||
rea: z.object({
|
||||
command: z.literal("node"),
|
||||
args: z.tuple([
|
||||
z.literal("./scripts/plugin-mcp-launcher.mjs"),
|
||||
z.string(),
|
||||
]),
|
||||
cwd: z.literal("."),
|
||||
startup_timeout_sec: z.number().int().positive(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const sectionsSchema = z.object({
|
||||
versioning: z.string().optional(),
|
||||
"changelog-sections": z.array(
|
||||
@@ -161,14 +192,14 @@ async function validateCandidate(git, sha, version, commits) {
|
||||
const lock = await readJson(
|
||||
git,
|
||||
sha,
|
||||
"package-lock.json",
|
||||
"npm-shrinkwrap.json",
|
||||
versionSchema.extend({
|
||||
packages: z.object({ "": versionSchema }),
|
||||
}),
|
||||
);
|
||||
versions.push(
|
||||
["package-lock.json", lock.version],
|
||||
["package-lock.json packages.root", lock.packages[""].version],
|
||||
["npm-shrinkwrap.json", lock.version],
|
||||
["npm-shrinkwrap.json packages.root", lock.packages[""].version],
|
||||
);
|
||||
const server = await readJson(
|
||||
git,
|
||||
@@ -189,6 +220,21 @@ async function validateCandidate(git, sha, version, commits) {
|
||||
["server.json", server.version],
|
||||
["server.json rea-agents package", npmPackage.version],
|
||||
);
|
||||
// A stale manifest keeps the previous plugin cache key or launches the
|
||||
// previous server, so both are version authority like server.json.
|
||||
const launches = [];
|
||||
for (const path of PLUGIN_MANIFEST_PATHS) {
|
||||
const manifest =
|
||||
path === ".codex-plugin/plugin.json"
|
||||
? await readJson(git, sha, path, codexPluginManifestSchema)
|
||||
: await readJson(git, sha, path, inlinePluginManifestSchema);
|
||||
versions.push([path, manifest.version]);
|
||||
const server =
|
||||
path === ".codex-plugin/plugin.json"
|
||||
? await readJson(git, sha, ".mcp.json", codexServerConfigSchema)
|
||||
: manifest;
|
||||
launches.push([path, server.mcpServers.rea.args[1]]);
|
||||
}
|
||||
// Catalogs are generated and checked from this candidate by the release job;
|
||||
// they are not version authority stored in the immutable source tree.
|
||||
const generated = await git([
|
||||
@@ -210,6 +256,16 @@ async function validateCandidate(git, sha, version, commits) {
|
||||
);
|
||||
}
|
||||
}
|
||||
// Plugin JSON stores only a scalar version. The launcher owns the fixed
|
||||
// package name and constructs the complete registry specifier at runtime.
|
||||
const requiredLaunch = version;
|
||||
for (const [location, launched] of launches) {
|
||||
if (launched !== requiredLaunch) {
|
||||
throw new Error(
|
||||
`${location}@${sha} launches version ${launched ?? "no version"}; release branch requires ${requiredLaunch}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const notes = releaseNotes(
|
||||
await git(["show", `${sha}:CHANGELOG.md`]),
|
||||
version,
|
||||
|
||||
@@ -365,6 +365,60 @@ export function verifyReleaseNotes(raw, text, body) {
|
||||
};
|
||||
}
|
||||
|
||||
const VERSION_ONLY_PLUGIN_METADATA = new Set([
|
||||
".claude-plugin/plugin.json",
|
||||
".codex-plugin/plugin.json",
|
||||
".mcp.json",
|
||||
".claude-plugin/marketplace.json",
|
||||
".agents/plugins/marketplace.json",
|
||||
]);
|
||||
|
||||
/** Serialize plugin metadata with only its release-owned version fields blanked. */
|
||||
function pluginMetadataWithoutVersions(path, text) {
|
||||
const manifest = JSON.parse(text);
|
||||
if (manifest === null || typeof manifest !== "object") return text;
|
||||
if (path.endsWith("marketplace.json")) {
|
||||
if (Array.isArray(manifest.plugins))
|
||||
for (const plugin of manifest.plugins)
|
||||
if (
|
||||
plugin?.name === "rea" &&
|
||||
plugin?.source?.source === "npm" &&
|
||||
plugin.source.package === "rea-agents" &&
|
||||
typeof plugin.source.version === "string"
|
||||
)
|
||||
plugin.source.version = "";
|
||||
return JSON.stringify(manifest);
|
||||
}
|
||||
if ("version" in manifest) manifest.version = "";
|
||||
const launch = manifest.mcpServers?.rea?.args;
|
||||
// Only the scalar pinned version is release-owned; the launcher owns the
|
||||
// fixed package name and the executable/path remain product behavior.
|
||||
if (Array.isArray(launch) && typeof launch[1] === "string") launch[1] = "";
|
||||
return JSON.stringify(manifest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin metadata also carries behavior, so a release may change only
|
||||
* its version, pinned server launch, and exact REA marketplace version. Any other difference, an unreadable
|
||||
* side, or a manifest added by the release counts as a product change.
|
||||
*/
|
||||
async function changesOnlyPluginVersions(run, path, from, to) {
|
||||
try {
|
||||
return (
|
||||
pluginMetadataWithoutVersions(
|
||||
path,
|
||||
await run("git", ["show", `${from}:${path}`]),
|
||||
) ===
|
||||
pluginMetadataWithoutVersions(
|
||||
path,
|
||||
await run("git", ["show", `${to}:${path}`]),
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reject stale inventories and product changes made after the editorial target. */
|
||||
export async function verifyInventoryHistory(run, raw, source) {
|
||||
const inventory = releaseInventorySchema.parse(raw);
|
||||
@@ -416,6 +470,7 @@ export async function verifyInventoryHistory(run, raw, source) {
|
||||
".release-please-manifest.json",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"npm-shrinkwrap.json",
|
||||
"server.json",
|
||||
"src/generatedPackageMetadata.ts",
|
||||
"docs/installation.md",
|
||||
@@ -433,7 +488,16 @@ export async function verifyInventoryHistory(run, raw, source) {
|
||||
)
|
||||
.split("\0")
|
||||
.filter(Boolean);
|
||||
const product = paths.filter((path) => !allowed.has(path));
|
||||
const product = [];
|
||||
for (const path of paths) {
|
||||
if (allowed.has(path)) continue;
|
||||
if (
|
||||
VERSION_ONLY_PLUGIN_METADATA.has(path) &&
|
||||
(await changesOnlyPluginVersions(run, path, inventory.target, resolved))
|
||||
)
|
||||
continue;
|
||||
product.push(path);
|
||||
}
|
||||
if (product.length)
|
||||
throw new Error(
|
||||
`Product changed after notes target; regenerate inventory: ${product.join(", ")}`,
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { access, mkdtemp, rm } from "node:fs/promises";
|
||||
import { constants, tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Keep the package name at the launcher boundary so release automation updates
|
||||
// only a scalar version in plugin JSON, never a compound package specifier.
|
||||
// Git plugins launch the registry release. Windows source plugins additionally
|
||||
// require a build and native addon for the owned process supervisor.
|
||||
const [version, ...extra] = process.argv.slice(2);
|
||||
if (
|
||||
extra.length !== 0 ||
|
||||
!/^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/u.test(version ?? "")
|
||||
) {
|
||||
process.stderr.write(
|
||||
"REA plugin requires one exact rea-agents@VERSION pin.\n",
|
||||
);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
await launch(`rea-agents@${version}`);
|
||||
}
|
||||
|
||||
async function launch(packageSpecifier) {
|
||||
// npm installs already carry the release-pinned runtime. Run it in this
|
||||
// process so the plugin keeps the caller's working directory and the
|
||||
// runtime retains ownership of provider and worker cleanup.
|
||||
const { default: bundledPackage } = await import("../package.json", {
|
||||
with: { type: "json" },
|
||||
});
|
||||
const sourceCheckout = await isSourceCheckout();
|
||||
let bundledRuntimeMatchesPin =
|
||||
!sourceCheckout &&
|
||||
packageSpecifier === `${bundledPackage.name}@${bundledPackage.version}`;
|
||||
if (bundledRuntimeMatchesPin) {
|
||||
try {
|
||||
await access(new URL("../dist/mcp/main.js", import.meta.url));
|
||||
} catch (cause) {
|
||||
if (!isMissing(cause)) throw cause;
|
||||
bundledRuntimeMatchesPin = false;
|
||||
}
|
||||
}
|
||||
if (bundledRuntimeMatchesPin) {
|
||||
process.argv = [
|
||||
process.execPath,
|
||||
fileURLToPath(new URL("./rea.mjs", import.meta.url)),
|
||||
"mcp",
|
||||
];
|
||||
await import("./rea.mjs");
|
||||
return;
|
||||
}
|
||||
|
||||
const runnerModule = await import(
|
||||
sourceCheckout
|
||||
? "../src/application/PackageRunnerCommand.mjs"
|
||||
: "../dist/application/PackageRunnerCommand.mjs"
|
||||
);
|
||||
const { packageRunnerCommand } = runnerModule;
|
||||
const [command, ...args] = packageRunnerCommand(
|
||||
"npx",
|
||||
packageSpecifier,
|
||||
process.platform,
|
||||
);
|
||||
// npm can satisfy the exact pin from a same-version local checkout. An
|
||||
// empty owned cwd prevents a Git-installed plugin or caller project from
|
||||
// shadowing the registry package with unbuilt/different local files.
|
||||
let workingDirectory;
|
||||
try {
|
||||
workingDirectory = await mkdtemp(join(tmpdir(), "rea-plugin-launcher-"));
|
||||
} catch (cause) {
|
||||
process.stderr.write(
|
||||
`REA plugin could not create its working directory: ${cause.message}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
await launchWindowsFallback(command, args, workingDirectory);
|
||||
return;
|
||||
}
|
||||
const child = spawn(command, args, {
|
||||
cwd: workingDirectory,
|
||||
// A dedicated POSIX process group gives this launcher ownership of the
|
||||
// package runner and any server it starts.
|
||||
detached: true,
|
||||
stdio: "inherit",
|
||||
shell: false,
|
||||
});
|
||||
let spawnFailed = false;
|
||||
const signals = new Map();
|
||||
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
||||
const forward = () => {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
try {
|
||||
if (process.platform !== "win32" && child.pid !== undefined)
|
||||
process.kill(-child.pid, signal);
|
||||
else child.kill(signal);
|
||||
} catch (cause) {
|
||||
if (!isMissingProcess(cause))
|
||||
process.stderr.write(
|
||||
`REA plugin could not forward ${signal}: ${cause.message}\n`,
|
||||
);
|
||||
}
|
||||
};
|
||||
signals.set(signal, forward);
|
||||
process.on(signal, forward);
|
||||
}
|
||||
const stopForwarding = () => {
|
||||
for (const [signal, listener] of signals) process.off(signal, listener);
|
||||
};
|
||||
child.once("error", (cause) => {
|
||||
spawnFailed = true;
|
||||
stopForwarding();
|
||||
process.stderr.write(
|
||||
`REA plugin could not start ${command}: ${cause.message}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
child.once("close", async (code, signal) => {
|
||||
stopForwarding();
|
||||
let cleanupFailed = false;
|
||||
try {
|
||||
await rm(workingDirectory, { recursive: true });
|
||||
} catch (cause) {
|
||||
cleanupFailed = true;
|
||||
process.stderr.write(
|
||||
`REA plugin retained its working directory at ${workingDirectory}: ${cause.message}\n`,
|
||||
);
|
||||
}
|
||||
if (spawnFailed) return;
|
||||
if (signal !== null && process.platform !== "win32") {
|
||||
// Preserve signal termination after the owned child has closed its streams.
|
||||
process.kill(process.pid, signal);
|
||||
} else {
|
||||
process.exitCode =
|
||||
code === 0 && cleanupFailed
|
||||
? 1
|
||||
: (code ?? 128 + (constants.signals[signal] ?? 1));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function isSourceCheckout() {
|
||||
// Published artifacts exclude src/. Also reject Git worktrees (whose .git is
|
||||
// a file) and source exports where Git metadata has been stripped.
|
||||
for (const path of ["../.git", "../src"]) {
|
||||
try {
|
||||
await access(new URL(path, import.meta.url));
|
||||
return true;
|
||||
} catch (cause) {
|
||||
if (!isMissing(cause)) throw cause;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function launchWindowsFallback(command, args, workingDirectory) {
|
||||
let spawnOwnedProviderProcess;
|
||||
try {
|
||||
({ spawnOwnedProviderProcess } =
|
||||
await import("../dist/process/ProviderProcess.js"));
|
||||
} catch (cause) {
|
||||
await rm(workingDirectory, { recursive: true }).catch(() => {});
|
||||
process.stderr.write(
|
||||
`REA Windows source plugins require npm ci && npm run build:cached and the Windows x64 native addon before loading the plugin. The owned process supervisor is unavailable: ${cause.message}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
let owned;
|
||||
try {
|
||||
owned = await spawnOwnedProviderProcess({
|
||||
command,
|
||||
arguments: args,
|
||||
runId: randomUUID(),
|
||||
cwd: workingDirectory,
|
||||
stdin: "pipe",
|
||||
platform: "win32",
|
||||
});
|
||||
} catch (cause) {
|
||||
await rm(workingDirectory, { recursive: true }).catch(() => {});
|
||||
process.stderr.write(
|
||||
`REA plugin could not start the owned Windows process tree: ${cause.message}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const child = owned.process;
|
||||
child.stdout?.pipe(process.stdout);
|
||||
child.stderr?.pipe(process.stderr);
|
||||
if (child.stdin === null || child.stdin === undefined) {
|
||||
const cleanup = await owned.cleanup?.();
|
||||
if (cleanup?.cleaned === true)
|
||||
await rm(workingDirectory, { recursive: true }).catch(() => {});
|
||||
else
|
||||
process.stderr.write(
|
||||
`REA plugin retained its Windows process ownership directory at ${workingDirectory} because cleanup was not verified.\n`,
|
||||
);
|
||||
process.stderr.write(
|
||||
"REA plugin Windows process supervisor did not provide protocol stdin.\n",
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
process.stdin.pipe(child.stdin);
|
||||
let cleanupFailed = false;
|
||||
let cleanupPromise;
|
||||
const signals = new Map();
|
||||
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
||||
const forward = () => {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
cleanupPromise ??= owned
|
||||
.cleanup?.()
|
||||
.then((result) => {
|
||||
if (!result.cleaned) {
|
||||
cleanupFailed = true;
|
||||
process.stderr.write(
|
||||
`REA plugin could not verify Windows process-tree cleanup: ${result.reason}\n`,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((cause) => {
|
||||
cleanupFailed = true;
|
||||
process.stderr.write(
|
||||
`REA plugin Windows process-tree cleanup failed: ${cause.message}\n`,
|
||||
);
|
||||
});
|
||||
};
|
||||
signals.set(signal, forward);
|
||||
process.on(signal, forward);
|
||||
}
|
||||
child.once("error", (cause) => {
|
||||
process.stderr.write(
|
||||
`REA plugin Windows process supervisor failed: ${cause.message}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
child.once("close", async (code) => {
|
||||
for (const [signal, listener] of signals) process.off(signal, listener);
|
||||
await cleanupPromise;
|
||||
if (!cleanupFailed) {
|
||||
try {
|
||||
await rm(workingDirectory, { recursive: true });
|
||||
} catch (cause) {
|
||||
cleanupFailed = true;
|
||||
process.stderr.write(
|
||||
`REA plugin retained its working directory at ${workingDirectory}: ${cause.message}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
process.exitCode = code === 0 && cleanupFailed ? 1 : (code ?? 1);
|
||||
});
|
||||
}
|
||||
|
||||
function isMissing(cause) {
|
||||
return (
|
||||
typeof cause === "object" &&
|
||||
cause !== null &&
|
||||
"code" in cause &&
|
||||
cause.code === "ENOENT"
|
||||
);
|
||||
}
|
||||
|
||||
function isMissingProcess(cause) {
|
||||
return (
|
||||
typeof cause === "object" &&
|
||||
cause !== null &&
|
||||
"code" in cause &&
|
||||
cause.code === "ESRCH"
|
||||
);
|
||||
}
|
||||
@@ -3,13 +3,14 @@ import {
|
||||
requireMcpToolError,
|
||||
} from "./lib/mcp-verifier-results.mjs";
|
||||
import assert from "node:assert/strict";
|
||||
import { join } from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { readFile, rm } from "node:fs/promises";
|
||||
import { readFile, realpath, rm } from "node:fs/promises";
|
||||
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { PRODUCT_IDENTITY } from "../dist/identity.js";
|
||||
import { MCP_STARTUP_POLICY } from "../dist/mcpStartupPolicy.js";
|
||||
import { parseEvidence } from "../dist/domain/evidence.js";
|
||||
import * as prompts from "./verify-package-prompts.mjs";
|
||||
@@ -246,7 +247,7 @@ const verifyMcpInlineArtifactEvidence = async (
|
||||
}
|
||||
};
|
||||
|
||||
/** Connect to the packaged MCP server and verify catalog and Evidence composition. */
|
||||
/** Check CLI diagnostics, then verify catalog and Evidence through the installed plugin. */
|
||||
export async function verifyPackageMcp({
|
||||
cli,
|
||||
environment,
|
||||
@@ -267,9 +268,13 @@ export async function verifyPackageMcp({
|
||||
diagnosed.inventory?.tools?.expected
|
||||
)
|
||||
throw new Error("packaged production MCP doctor failed");
|
||||
const scriptsDirectory = dirname(await realpath(cli));
|
||||
const transport = new StdioClientTransport({
|
||||
command: cli,
|
||||
args: ["mcp"],
|
||||
command: process.execPath,
|
||||
args: [
|
||||
join(scriptsDirectory, "plugin-mcp-launcher.mjs"),
|
||||
PRODUCT_IDENTITY.packageVersion,
|
||||
],
|
||||
env: {
|
||||
...environment,
|
||||
},
|
||||
|
||||
@@ -25,6 +25,23 @@ export async function verifyPackagePack({ root, workspace }) {
|
||||
const packedManifest = JSON.parse(
|
||||
(await exec("tar", ["-xOf", tarball, "package/package.json"])).stdout,
|
||||
);
|
||||
const packedLock = JSON.parse(
|
||||
(await exec("tar", ["-xOf", tarball, "package/npm-shrinkwrap.json"]))
|
||||
.stdout,
|
||||
);
|
||||
if (
|
||||
packedManifest.overrides !== undefined ||
|
||||
![2, 3].includes(packedLock.lockfileVersion) ||
|
||||
packedLock.version !== packedManifest.version ||
|
||||
packedLock.packages?.[""].version !== packedManifest.version ||
|
||||
JSON.stringify(packedLock.packages?.[""].dependencies) !==
|
||||
JSON.stringify(packedManifest.dependencies) ||
|
||||
JSON.stringify(packedLock.packages?.[""].optionalDependencies) !==
|
||||
JSON.stringify(packedManifest.optionalDependencies)
|
||||
)
|
||||
throw new Error(
|
||||
"package is not installable under the Claude plugin dependency policy",
|
||||
);
|
||||
if (
|
||||
packedManifest.scripts?.postinstall !== undefined ||
|
||||
packedManifest.dependencies?.["node-pty"] !== undefined ||
|
||||
@@ -42,6 +59,10 @@ export async function verifyPackagePack({ root, workspace }) {
|
||||
throw new Error(
|
||||
"package included authored skill sources instead of only the generated bundle",
|
||||
);
|
||||
if (packedFiles.some((path) => path.startsWith("package/src/")))
|
||||
throw new Error(
|
||||
"Packaged runtime must not masquerade as a source checkout",
|
||||
);
|
||||
if (packedFiles.some((path) => path.startsWith("package/.cache/")))
|
||||
throw new Error("package included development cache or test metadata");
|
||||
const skill = (
|
||||
@@ -60,6 +81,55 @@ export async function verifyPackagePack({ root, workspace }) {
|
||||
throw new Error(
|
||||
"packaged skill metadata did not match the catalog shipped in the package",
|
||||
);
|
||||
for (const path of [
|
||||
".claude-plugin/plugin.json",
|
||||
".codex-plugin/plugin.json",
|
||||
]) {
|
||||
const manifest = JSON.parse(
|
||||
(await exec("tar", ["-xOf", tarball, `package/${path}`])).stdout,
|
||||
);
|
||||
const serverConfig = path.startsWith(".codex")
|
||||
? JSON.parse(
|
||||
(await exec("tar", ["-xOf", tarball, "package/.mcp.json"])).stdout,
|
||||
)
|
||||
: manifest;
|
||||
const expectedArgs = [
|
||||
`${path.startsWith(".claude") ? "${CLAUDE_PLUGIN_ROOT}/" : "./"}scripts/plugin-mcp-launcher.mjs`,
|
||||
packedManifest.version,
|
||||
];
|
||||
if (
|
||||
manifest.version !== packedManifest.version ||
|
||||
manifest.skills !== "./skills" ||
|
||||
(path.startsWith(".codex")
|
||||
? manifest.mcpServers !== "./.mcp.json" ||
|
||||
serverConfig.mcpServers?.rea?.cwd !== "." ||
|
||||
serverConfig.mcpServers?.rea?.startup_timeout_sec !== 30
|
||||
: false) ||
|
||||
serverConfig.mcpServers?.rea?.command !== "node" ||
|
||||
JSON.stringify(serverConfig.mcpServers?.rea?.args) !==
|
||||
JSON.stringify(expectedArgs)
|
||||
)
|
||||
throw new Error(
|
||||
`Packaged plugin ${path} does not match the exact runtime and canonical skill release`,
|
||||
);
|
||||
}
|
||||
if (!packedFiles.includes("package/.mcp.json"))
|
||||
throw new Error("Packaged Codex MCP server declaration is missing");
|
||||
for (const path of [
|
||||
"scripts/plugin-mcp-launcher.mjs",
|
||||
"dist/application/PackageRunnerCommand.mjs",
|
||||
])
|
||||
if (!packedFiles.includes(`package/${path}`))
|
||||
throw new Error(
|
||||
`Packaged plugin launcher dependency is missing: ${path}`,
|
||||
);
|
||||
if (
|
||||
skill.includes(`${packedManifest.name}@latest`) ||
|
||||
!skill.includes(`${packedManifest.name}@${packedManifest.version}`)
|
||||
)
|
||||
throw new Error(
|
||||
"Packaged plugin skill recovery is not pinned to the shipped runtime",
|
||||
);
|
||||
if (
|
||||
packedFiles.some(
|
||||
(path) => path.includes("__pycache__") || path.endsWith(".pyc"),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { basename, isAbsolute, resolve } from "node:path";
|
||||
import { valid } from "semver";
|
||||
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { packageRunnerCommand } from "./PackageRunnerCommand.mjs";
|
||||
|
||||
/** Package runners whose pinned launcher setup can register. */
|
||||
export type PackageRunner = "npx" | "bunx";
|
||||
@@ -53,14 +54,12 @@ export const withoutPackageRunnerIdentity = (
|
||||
export const packageRunnerRegistrationCommand = (
|
||||
runner: PackageRunner,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): readonly string[] => {
|
||||
const command =
|
||||
runner === "npx"
|
||||
? ["npx", "-y", PRODUCT_IDENTITY.registrationPackageSpecifier, "mcp"]
|
||||
: ["bunx", PRODUCT_IDENTITY.registrationPackageSpecifier, "mcp"];
|
||||
// Both runners may be .cmd shims on Windows, which clients cannot spawn directly.
|
||||
return platform === "win32" ? ["cmd.exe", "/d", "/c", ...command] : command;
|
||||
};
|
||||
): readonly string[] =>
|
||||
packageRunnerCommand(
|
||||
runner,
|
||||
PRODUCT_IDENTITY.registrationPackageSpecifier,
|
||||
platform,
|
||||
);
|
||||
|
||||
/** Resolve a pinned npx launcher that stdio clients can spawn on the host. */
|
||||
export const npxRegistrationCommand = (
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/** Exact host argv for a pinned registry package's MCP entry point. */
|
||||
export function packageRunnerCommand(
|
||||
runner: "npx" | "bunx",
|
||||
packageSpecifier: string,
|
||||
platform: NodeJS.Platform,
|
||||
): readonly string[];
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Host argv shared by setup registrations and the source-only plugin launcher. */
|
||||
export const packageRunnerCommand = (runner, packageSpecifier, platform) => {
|
||||
if (runner !== "npx" && runner !== "bunx")
|
||||
throw new Error("Unsupported package runner");
|
||||
// Windows cmd.exe consumes these tokens itself. Package registrations use
|
||||
// exact registry references, never shell expressions or arbitrary operands.
|
||||
if (!/^[@A-Za-z0-9_./+-]+$/u.test(packageSpecifier))
|
||||
throw new Error("Package runner requires a shell-safe registry reference");
|
||||
const command =
|
||||
runner === "npx"
|
||||
? ["npx", "-y", packageSpecifier, "mcp"]
|
||||
: ["bunx", packageSpecifier, "mcp"];
|
||||
// npm's .cmd shim is not a native executable. Disable cmd AutoRun, matching
|
||||
// the setup registration policy, and keep all dynamic values out of syntax.
|
||||
return platform === "win32" ? ["cmd.exe", "/d", "/c", ...command] : command;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Generated from package.json and package-lock.json; do not edit. */
|
||||
/** Generated from package.json and npm-shrinkwrap.json; do not edit. */
|
||||
export const PACKAGE_METADATA = {
|
||||
name: "rea-agents",
|
||||
version: "6.4.0", // x-release-please-version
|
||||
|
||||
@@ -130,7 +130,7 @@ printf '%s\n' 'C:\Users\runner with spaces\AppData\Local\npm-cache'
|
||||
"${{ github.event.repository.default_branch }}",
|
||||
);
|
||||
expect(policy?.env?.LOCKFILES_HASH).toBe(
|
||||
"${{ hashFiles('package-lock.json', 'npm-shrinkwrap.json', '.npmrc') }}",
|
||||
"${{ hashFiles('npm-shrinkwrap.json', '.npmrc') }}",
|
||||
);
|
||||
expect(policy?.run).toContain(
|
||||
'cache_key="npm-download-${RUNNER_OS}-${runtime_arch}-${LOCKFILES_HASH}"',
|
||||
|
||||
@@ -634,7 +634,7 @@ it.each([
|
||||
{ code: true, package: true, evm: true, browser: true },
|
||||
],
|
||||
[
|
||||
"package-lock.json",
|
||||
"npm-shrinkwrap.json",
|
||||
{ code: true, package: true, evm: true, browser: true },
|
||||
],
|
||||
[
|
||||
|
||||
@@ -23,7 +23,7 @@ const fixture = async (
|
||||
const root = await createTestTempDirectory("rea-deps-");
|
||||
await mkdir(join(root, "node_modules"));
|
||||
await writeFile(
|
||||
join(root, "package-lock.json"),
|
||||
join(root, "npm-shrinkwrap.json"),
|
||||
JSON.stringify({
|
||||
packages: {
|
||||
"": {
|
||||
@@ -103,7 +103,7 @@ describe("dependency install freshness", () => {
|
||||
expect(seeded.status, `${seeded.stdout}${seeded.stderr}`).toBe(0);
|
||||
|
||||
const rootLock = asRecord(
|
||||
JSON.parse(await readFile(join(root, "package-lock.json"), "utf8")),
|
||||
JSON.parse(await readFile(join(root, "npm-shrinkwrap.json"), "utf8")),
|
||||
);
|
||||
const lockPackages = asRecord(rootLock?.packages);
|
||||
const rootPackage = asRecord(lockPackages?.[""]);
|
||||
@@ -191,7 +191,7 @@ const createBuildCacheFixture = async (): Promise<string> => {
|
||||
throw new Error("package is missing its cached build command");
|
||||
const paths = [
|
||||
".nvmrc",
|
||||
"package-lock.json",
|
||||
"npm-shrinkwrap.json",
|
||||
"turbo.json",
|
||||
"tsconfig.json",
|
||||
"tsconfig.build.json",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterAll, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
@@ -19,6 +19,32 @@ const verifier = fileURLToPath(
|
||||
new URL("../../../scripts/verify-release-checkpoint.mjs", import.meta.url),
|
||||
);
|
||||
|
||||
const pluginManifestPaths = [
|
||||
".claude-plugin/plugin.json",
|
||||
".codex-plugin/plugin.json",
|
||||
] as const;
|
||||
|
||||
const pluginManifest = (version: string, launch = version) => ({
|
||||
version,
|
||||
mcpServers: {
|
||||
rea: {
|
||||
command: "node",
|
||||
args: ["${CLAUDE_PLUGIN_ROOT}/scripts/plugin-mcp-launcher.mjs", launch],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const codexServerConfig = (version: string, launch = version) => ({
|
||||
mcpServers: {
|
||||
rea: {
|
||||
command: "node",
|
||||
args: ["./scripts/plugin-mcp-launcher.mjs", launch],
|
||||
cwd: ".",
|
||||
startup_timeout_sec: 30,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
async function metadata(
|
||||
directory: string,
|
||||
version: string,
|
||||
@@ -27,9 +53,17 @@ async function metadata(
|
||||
) {
|
||||
await mkdir(join(directory, "docs"), { recursive: true });
|
||||
await mkdir(join(directory, "src"), { recursive: true });
|
||||
for (const path of pluginManifestPaths)
|
||||
await mkdir(join(directory, dirname(path)), { recursive: true });
|
||||
const files = {
|
||||
".claude-plugin/plugin.json": pluginManifest(version),
|
||||
".codex-plugin/plugin.json": {
|
||||
version,
|
||||
mcpServers: "./.mcp.json",
|
||||
},
|
||||
".mcp.json": codexServerConfig(version),
|
||||
"package.json": { name: "rea-agents", version },
|
||||
"package-lock.json": { version, packages: { "": { version } } },
|
||||
"npm-shrinkwrap.json": { version, packages: { "": { version } } },
|
||||
".release-please-manifest.json": { ".": version },
|
||||
"server.json": {
|
||||
version,
|
||||
@@ -534,7 +568,7 @@ it("validates candidate options before inspecting the working directory", async
|
||||
});
|
||||
|
||||
it.each([
|
||||
"package-lock.json",
|
||||
"npm-shrinkwrap.json",
|
||||
"server.json",
|
||||
"src/generatedPackageMetadata.ts",
|
||||
])("rejects inconsistent version metadata in %s", async (path) => {
|
||||
@@ -545,7 +579,7 @@ it.each([
|
||||
path.endsWith(".ts")
|
||||
? 'export const METADATA = {\n version: "5.1.0",\n};\n'
|
||||
: JSON.stringify(
|
||||
path === "package-lock.json"
|
||||
path === "npm-shrinkwrap.json"
|
||||
? { version: "6.0.0", packages: { "": { version: "5.1.0" } } }
|
||||
: path === "server.json"
|
||||
? {
|
||||
@@ -565,6 +599,51 @@ it.each([
|
||||
});
|
||||
});
|
||||
|
||||
it.each(
|
||||
pluginManifestPaths.map((path) => ({
|
||||
path,
|
||||
stale: "version",
|
||||
manifest:
|
||||
path === ".codex-plugin/plugin.json"
|
||||
? { version: "5.1.0", mcpServers: "./.mcp.json" }
|
||||
: pluginManifest("5.1.0"),
|
||||
failure: "declares 5.1.0; release branch requires 6.0.0",
|
||||
})),
|
||||
)("rejects a plugin $stale in $path", async ({ path, manifest, failure }) => {
|
||||
const f = await fixture();
|
||||
await candidate(f, "6.0.0");
|
||||
await writeFile(join(f.directory, path), JSON.stringify(manifest));
|
||||
await git(f.directory, ["add", "."]);
|
||||
await git(f.directory, ["commit", "-m", "chore: stale plugin manifest"]);
|
||||
await expect(verify(f)).rejects.toMatchObject({
|
||||
code: 1,
|
||||
stderr: expect.stringContaining(failure),
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
path: ".claude-plugin/plugin.json",
|
||||
manifest: pluginManifest("6.0.0", "5.1.0"),
|
||||
},
|
||||
{
|
||||
path: ".mcp.json",
|
||||
manifest: codexServerConfig("6.0.0", "5.1.0"),
|
||||
},
|
||||
])("reads the release pin from $path", async ({ path, manifest }) => {
|
||||
const f = await fixture();
|
||||
await candidate(f, "6.0.0");
|
||||
await writeFile(join(f.directory, path), JSON.stringify(manifest));
|
||||
await git(f.directory, ["add", "."]);
|
||||
await git(f.directory, ["commit", "-m", "chore: stale plugin launch"]);
|
||||
await expect(verify(f)).rejects.toMatchObject({
|
||||
code: 1,
|
||||
stderr: expect.stringContaining(
|
||||
`launches version 5.1.0; release branch requires 6.0.0`,
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
it("checks a publish merge using its pre-release first parent baseline", async () => {
|
||||
const f = await fixture();
|
||||
const sourceSha = await candidate(f, "6.0.0");
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
readFile,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { delimiter, join } from "node:path";
|
||||
import { delimiter, dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterAll, beforeAll, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
@@ -43,6 +43,59 @@ interface ReleaseNotesSeed {
|
||||
let seed: ReleaseNotesSeed | undefined;
|
||||
let seedWorkspaceRoot: string | undefined;
|
||||
|
||||
const pluginManifests = [
|
||||
".claude-plugin/plugin.json",
|
||||
".codex-plugin/plugin.json",
|
||||
] as const;
|
||||
const pluginServerConfig = ".mcp.json";
|
||||
|
||||
const pluginMarketplaces = [
|
||||
".claude-plugin/marketplace.json",
|
||||
".agents/plugins/marketplace.json",
|
||||
] as const;
|
||||
const pluginMarketplace = (version: string, packageName = "rea-agents") => ({
|
||||
plugins: [
|
||||
{ name: "rea", source: { source: "npm", package: packageName, version } },
|
||||
],
|
||||
});
|
||||
|
||||
const pluginServer = (version: string, command = "node") => ({
|
||||
mcpServers: {
|
||||
rea: {
|
||||
command,
|
||||
args: ["./scripts/plugin-mcp-launcher.mjs", version],
|
||||
startup_timeout_sec: 30,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const pluginManifest = (
|
||||
version: string,
|
||||
{ skills = ["./skill"], command = "node" } = {},
|
||||
) => ({
|
||||
version,
|
||||
skills,
|
||||
mcpServers: {
|
||||
rea: {
|
||||
command,
|
||||
args: ["${CLAUDE_PLUGIN_ROOT}/scripts/plugin-mcp-launcher.mjs", version],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const pluginManifestFor = (
|
||||
path: (typeof pluginManifests)[number],
|
||||
version: string,
|
||||
options: { skills?: string[]; command?: string } = {},
|
||||
) =>
|
||||
path === ".codex-plugin/plugin.json"
|
||||
? {
|
||||
version,
|
||||
skills: options.skills ?? ["./skill"],
|
||||
mcpServers: "./.mcp.json",
|
||||
}
|
||||
: pluginManifest(version, options);
|
||||
|
||||
beforeAll(async () => {
|
||||
const workspace = await createTestWorkspace("rea-release-notes-seed-");
|
||||
seedWorkspaceRoot = workspace.root;
|
||||
@@ -58,6 +111,24 @@ beforeAll(async () => {
|
||||
join(directory, ".release-please-manifest.json"),
|
||||
JSON.stringify({ ".": "6.3.0" }),
|
||||
);
|
||||
for (const path of pluginManifests) {
|
||||
await mkdir(join(directory, dirname(path)), { recursive: true });
|
||||
await writeFile(
|
||||
join(directory, path),
|
||||
JSON.stringify(pluginManifestFor(path, "6.3.0")),
|
||||
);
|
||||
}
|
||||
await writeFile(
|
||||
join(directory, pluginServerConfig),
|
||||
JSON.stringify(pluginServer("6.3.0")),
|
||||
);
|
||||
for (const path of pluginMarketplaces) {
|
||||
await mkdir(join(directory, dirname(path)), { recursive: true });
|
||||
await writeFile(
|
||||
join(directory, path),
|
||||
JSON.stringify(pluginMarketplace("6.3.0")),
|
||||
);
|
||||
}
|
||||
await git(directory, ["add", "."]);
|
||||
await git(directory, ["commit", "-m", "chore: baseline"]);
|
||||
const base = await git(directory, ["rev-parse", "HEAD"]);
|
||||
@@ -368,9 +439,30 @@ it("rejects lost grouped credits, fabricated references, bots, and stale PR bodi
|
||||
|
||||
it("binds publication to complete recorded history and the reviewed base", async () => {
|
||||
const f = await fixture();
|
||||
await git(f.directory, ["add", "CHANGELOG.md"]);
|
||||
for (const path of pluginManifests)
|
||||
await writeFile(
|
||||
join(f.directory, path),
|
||||
JSON.stringify(pluginManifestFor(path, "6.4.0")),
|
||||
);
|
||||
await writeFile(
|
||||
join(f.directory, pluginServerConfig),
|
||||
JSON.stringify(pluginServer("6.4.0")),
|
||||
);
|
||||
for (const path of pluginMarketplaces)
|
||||
await writeFile(
|
||||
join(f.directory, path),
|
||||
JSON.stringify(pluginMarketplace("6.4.0")),
|
||||
);
|
||||
await git(f.directory, [
|
||||
"add",
|
||||
"CHANGELOG.md",
|
||||
...pluginManifests,
|
||||
pluginServerConfig,
|
||||
...pluginMarketplaces,
|
||||
]);
|
||||
await git(f.directory, ["commit", "-m", "chore: release notes"]);
|
||||
const source = await git(f.directory, ["rev-parse", "HEAD"]);
|
||||
// Release PRs bump the plugin manifest version and pinned launch only.
|
||||
await f.invoke(["check", "--manifest", f.manifest, "--source", source]);
|
||||
await writeFile(join(f.directory, "product.txt"), "later product change");
|
||||
await git(f.directory, ["add", "product.txt"]);
|
||||
@@ -391,6 +483,74 @@ it("binds publication to complete recorded history and the reviewed base", async
|
||||
});
|
||||
});
|
||||
|
||||
it.each(pluginManifests)(
|
||||
"treats a release that changes the skill path in %s as a product change",
|
||||
async (path) => {
|
||||
const f = await fixture();
|
||||
await writeFile(
|
||||
join(f.directory, path),
|
||||
JSON.stringify(
|
||||
pluginManifestFor(path, "6.4.0", { skills: ["./another-skill"] }),
|
||||
),
|
||||
);
|
||||
await git(f.directory, ["add", "CHANGELOG.md", path]);
|
||||
await git(f.directory, ["commit", "-m", "chore: release notes"]);
|
||||
await expect(
|
||||
f.invoke(["check", "--manifest", f.manifest, "--source", "HEAD"]),
|
||||
).rejects.toMatchObject({
|
||||
stderr: expect.stringContaining(
|
||||
`Product changed after notes target; regenerate inventory: ${path}`,
|
||||
),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("accepts Release Please lockfile refreshes without allowing product changes", async () => {
|
||||
const f = await fixture();
|
||||
await writeFile(
|
||||
join(f.directory, "package-lock.json"),
|
||||
'{"lockfileVersion":3}',
|
||||
);
|
||||
await writeFile(
|
||||
join(f.directory, "npm-shrinkwrap.json"),
|
||||
'{"lockfileVersion":3}',
|
||||
);
|
||||
await git(f.directory, [
|
||||
"add",
|
||||
"CHANGELOG.md",
|
||||
"package-lock.json",
|
||||
"npm-shrinkwrap.json",
|
||||
]);
|
||||
await git(f.directory, ["commit", "-m", "chore: release lockfiles"]);
|
||||
await f.invoke(["check", "--manifest", f.manifest, "--source", "HEAD"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
path: ".claude-plugin/plugin.json",
|
||||
content: pluginManifest("6.4.0", { command: "other-launcher" }),
|
||||
},
|
||||
{
|
||||
path: pluginServerConfig,
|
||||
content: pluginServer("6.4.0", "other-launcher"),
|
||||
},
|
||||
])(
|
||||
"keeps launcher behavior in $path outside version-only metadata",
|
||||
async ({ path, content }) => {
|
||||
const f = await fixture();
|
||||
await writeFile(join(f.directory, path), JSON.stringify(content));
|
||||
await git(f.directory, ["add", "CHANGELOG.md", path]);
|
||||
await git(f.directory, ["commit", "-m", "chore: release notes"]);
|
||||
await expect(
|
||||
f.invoke(["check", "--manifest", f.manifest, "--source", "HEAD"]),
|
||||
).rejects.toMatchObject({
|
||||
stderr: expect.stringContaining(
|
||||
`Product changed after notes target; regenerate inventory: ${path}`,
|
||||
),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("checks the live merged PR and exact Git snapshot before either publication route", async () => {
|
||||
const f = await fixture();
|
||||
await mkdir(join(f.directory, "docs/releases"), { recursive: true });
|
||||
@@ -536,3 +696,23 @@ it("fails closed on partial GitHub errors and non-advancing pagination without s
|
||||
});
|
||||
await expect(access(output)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it.each(pluginMarketplaces)(
|
||||
"treats a marketplace package change in %s as product behavior",
|
||||
async (path) => {
|
||||
const f = await fixture();
|
||||
await writeFile(
|
||||
join(f.directory, path),
|
||||
JSON.stringify(pluginMarketplace("6.4.0", "another-package")),
|
||||
);
|
||||
await git(f.directory, ["add", "CHANGELOG.md", path]);
|
||||
await git(f.directory, ["commit", "-m", "chore: release notes"]);
|
||||
await expect(
|
||||
f.invoke(["check", "--manifest", f.manifest, "--source", "HEAD"]),
|
||||
).rejects.toMatchObject({
|
||||
stderr: expect.stringContaining(
|
||||
`Product changed after notes target; regenerate inventory: ${path}`,
|
||||
),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -78,7 +78,7 @@ const writeTaskFixture = async (
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
join(root, "package-lock.json"),
|
||||
join(root, "npm-shrinkwrap.json"),
|
||||
JSON.stringify({
|
||||
name: "rea-static-task-cache-fixture",
|
||||
version: "1.0.0",
|
||||
@@ -288,3 +288,38 @@ it("invalidates checker task hashes for Node, ignore, and VitePress config chang
|
||||
`${task} must ignore vitest.config.ts`,
|
||||
).toBe(initial.get(task));
|
||||
});
|
||||
|
||||
it.each(["compile", "typecheck"])(
|
||||
"invalidates %s for JavaScript owners, declarations, and the authoritative lock",
|
||||
async (task) => {
|
||||
const root = await createTestTempDirectory("rea-mjs-cache-inputs-");
|
||||
const cache = await createTestTempDirectory("rea-mjs-cache-store-");
|
||||
await mkdir(join(root, "src/application"), { recursive: true });
|
||||
await writeTaskFixture(root, [task], {
|
||||
[task]: 'node -e "process.exit(0)"',
|
||||
});
|
||||
const owner = join(root, "src/application/PackageRunnerCommand.mjs");
|
||||
const declaration = join(
|
||||
root,
|
||||
"src/application/PackageRunnerCommand.d.mts",
|
||||
);
|
||||
await writeFile(owner, "export const command = 1;\n");
|
||||
await writeFile(declaration, "export declare const command: number;\n");
|
||||
await workflowGit(root, ["init", "--quiet"]);
|
||||
await workflowGit(root, ["add", "."]);
|
||||
const initial = taskHash(root, task, cache);
|
||||
expect(taskHash(root, task, cache)).toBe(initial);
|
||||
await writeFile(owner, "export const command = 2;\n");
|
||||
expect(taskHash(root, task, cache)).not.toBe(initial);
|
||||
await writeFile(owner, "export const command = 1;\n");
|
||||
await writeFile(declaration, "export declare const command: string;\n");
|
||||
expect(taskHash(root, task, cache)).not.toBe(initial);
|
||||
await writeFile(declaration, "export declare const command: number;\n");
|
||||
expect(taskHash(root, task, cache)).toBe(initial);
|
||||
const lockPath = join(root, "npm-shrinkwrap.json");
|
||||
const lock = JSON.parse(await readFile(lockPath, "utf8"));
|
||||
lock.packages["node_modules/fixture"] = { version: "1.0.0" };
|
||||
await writeFile(lockPath, JSON.stringify(lock));
|
||||
expect(taskHash(root, task, cache)).not.toBe(initial);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -28,7 +28,7 @@ test("generates locked inventory and rejects stale notice metadata or preserved
|
||||
optional: true,
|
||||
};
|
||||
await writeFile(
|
||||
resolve(root, "package-lock.json"),
|
||||
resolve(root, "npm-shrinkwrap.json"),
|
||||
JSON.stringify({
|
||||
packages: {
|
||||
"": { name: "fixture", version: "1.0.0" },
|
||||
|
||||
@@ -94,7 +94,9 @@ const statusCapability = (
|
||||
describe("server and catalog identity", () => {
|
||||
it("derives package and SDK versions from canonical package metadata", async () => {
|
||||
const packageJson = JSON.parse(await readFile("package.json", "utf8"));
|
||||
const packageLock = JSON.parse(await readFile("package-lock.json", "utf8"));
|
||||
const packageLock = JSON.parse(
|
||||
await readFile("npm-shrinkwrap.json", "utf8"),
|
||||
);
|
||||
expect(PACKAGE_METADATA).toMatchObject({
|
||||
name: packageJson.name,
|
||||
version: packageJson.version,
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { copyFile, lstat, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { delimiter, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const launcher = fileURLToPath(
|
||||
new URL("../../../scripts/plugin-mcp-launcher.mjs", import.meta.url),
|
||||
);
|
||||
|
||||
const start = (path: string, version = "6.4.0-rc.1", entry = launcher) => {
|
||||
const child = spawn(process.execPath, [entry, version], {
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${path}${delimiter}${process.env.PATH ?? ""}`,
|
||||
},
|
||||
stdio: "pipe",
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on(
|
||||
"data",
|
||||
(bytes: Buffer) => (stdout += bytes.toString("utf8")),
|
||||
);
|
||||
child.stderr.on(
|
||||
"data",
|
||||
(bytes: Buffer) => (stderr += bytes.toString("utf8")),
|
||||
);
|
||||
const closed = new Promise<{
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}>((resolve, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("close", (code, signal) =>
|
||||
resolve({ code, signal, stdout, stderr }),
|
||||
);
|
||||
});
|
||||
onTestFinished(async () => {
|
||||
if (child.exitCode === null && child.signalCode === null)
|
||||
child.kill("SIGKILL");
|
||||
await closed;
|
||||
});
|
||||
return { child, closed };
|
||||
};
|
||||
|
||||
const fakeRunner = async (body: string) => {
|
||||
const root = await createTestTempDirectory("rea-plugin-launcher-");
|
||||
const bin = join(root, "plugin with spaces", "bin");
|
||||
await mkdir(bin, { recursive: true });
|
||||
await writeFile(join(bin, "npx"), `#!${process.execPath}\n${body}\n`, {
|
||||
mode: 0o755,
|
||||
});
|
||||
return bin;
|
||||
};
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"passes the exact pin and protocol streams through the source-only plugin launcher",
|
||||
async () => {
|
||||
const bin = await fakeRunner(`
|
||||
process.stderr.write(JSON.stringify(process.argv.slice(2)));
|
||||
process.stdin.pipe(process.stdout);
|
||||
process.stdin.on("end", () => { process.exitCode = 7; });
|
||||
`);
|
||||
const { child, closed } = start(bin, "6.4.0-rc.1");
|
||||
child.stdin.end('{"jsonrpc":"2.0","id":1}\n');
|
||||
expect(await closed).toEqual({
|
||||
code: 7,
|
||||
signal: null,
|
||||
stdout: '{"jsonrpc":"2.0","id":1}\n',
|
||||
stderr: '["-y","rea-agents@6.4.0-rc.1","mcp"]',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"forwards termination to the live package-runner child before exiting",
|
||||
async () => {
|
||||
const bin = await fakeRunner(`
|
||||
process.on("SIGTERM", () => { process.stderr.write("stopped"); process.exit(0); });
|
||||
process.stdout.write("ready");
|
||||
process.stdin.resume();
|
||||
`);
|
||||
const { child, closed } = start(bin);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
child.stdout.once("data", () => resolve());
|
||||
child.once("error", reject);
|
||||
});
|
||||
child.kill("SIGTERM");
|
||||
expect(await closed).toMatchObject({ code: 0, stderr: "stopped" });
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"stops a package runner and its long-lived server with stdin still open",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-plugin-process-tree-");
|
||||
const runnerMarker = join(root, "runner-stopped");
|
||||
const serverMarker = join(root, "server-stopped");
|
||||
const bin = await fakeRunner(`
|
||||
const { appendFileSync } = require("node:fs");
|
||||
const { spawn } = require("node:child_process");
|
||||
const server = spawn(process.execPath, ["-e", ${JSON.stringify(`
|
||||
const { appendFileSync } = require("node:fs");
|
||||
process.on("SIGTERM", () => {
|
||||
appendFileSync(${JSON.stringify(serverMarker)}, "stopped\\n");
|
||||
process.exit(0);
|
||||
});
|
||||
process.stdin.resume();
|
||||
process.stdout.write("server-ready\\n");
|
||||
`)}], { stdio: "inherit" });
|
||||
let stopping = false;
|
||||
process.on("SIGTERM", () => {
|
||||
stopping = true;
|
||||
appendFileSync(${JSON.stringify(runnerMarker)}, "stopped\\n");
|
||||
});
|
||||
process.stdin.resume();
|
||||
server.once("close", () => { if (stopping) process.exit(0); });
|
||||
`);
|
||||
const { child, closed } = start(bin);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => reject(new Error("package server did not become ready")),
|
||||
5_000,
|
||||
);
|
||||
child.stdout.on("data", (bytes: Buffer) => {
|
||||
if (bytes.toString("utf8").includes("server-ready\n")) {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
child.once("error", reject);
|
||||
});
|
||||
|
||||
// Keep the protocol pipe open: shutdown must reach the package runner and
|
||||
// its server process independently of EOF on stdin.
|
||||
child.kill("SIGTERM");
|
||||
expect(await closed).toMatchObject({ code: 0, signal: null });
|
||||
await expect(readFile(runnerMarker, "utf8")).resolves.toBe("stopped\n");
|
||||
await expect(readFile(serverMarker, "utf8")).resolves.toBe("stopped\n");
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"isolates local package resolution and removes the owned runner directory",
|
||||
async () => {
|
||||
const bin = await fakeRunner("process.stdout.write(process.cwd());");
|
||||
const { child, closed } = start(bin);
|
||||
child.stdin.end();
|
||||
const result = await closed;
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain("rea-plugin-launcher-");
|
||||
expect(result.stdout).not.toBe(process.cwd());
|
||||
await expect(lstat(result.stdout)).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects a shell expression instead of treating it as the pinned package", async () => {
|
||||
const root = await createTestTempDirectory("rea-plugin-invalid-pin-");
|
||||
const { child, closed } = start(root, "6.4.0&echo wrong");
|
||||
child.stdin.end();
|
||||
expect(await closed).toMatchObject({
|
||||
code: 1,
|
||||
stdout: "",
|
||||
stderr: "REA plugin requires one exact rea-agents@VERSION pin.\n",
|
||||
});
|
||||
});
|
||||
|
||||
const pluginFixture = async () => {
|
||||
const root = await createTestTempDirectory("rea-plugin-release-origin-");
|
||||
await mkdir(join(root, "scripts"));
|
||||
await mkdir(join(root, "dist/mcp"), { recursive: true });
|
||||
const entry = join(root, "scripts/plugin-mcp-launcher.mjs");
|
||||
await copyFile(launcher, entry);
|
||||
await writeFile(
|
||||
join(root, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "rea-agents",
|
||||
version: "6.4.0",
|
||||
type: "module",
|
||||
}),
|
||||
);
|
||||
await writeFile(join(root, "dist/mcp/main.js"), "");
|
||||
await writeFile(
|
||||
join(root, "scripts/rea.mjs"),
|
||||
'process.stdout.write("bundled-runtime");',
|
||||
);
|
||||
return { root, entry };
|
||||
};
|
||||
|
||||
it
|
||||
.skipIf(process.platform === "win32")
|
||||
.each(["directory", "worktree file", "exported source"])(
|
||||
"uses the registry pin even when a %s checkout has same-version dist",
|
||||
async (kind) => {
|
||||
const { root, entry } = await pluginFixture();
|
||||
await mkdir(join(root, "src/application"), { recursive: true });
|
||||
await copyFile(
|
||||
fileURLToPath(
|
||||
new URL(
|
||||
"../../../src/application/PackageRunnerCommand.mjs",
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
join(root, "src/application/PackageRunnerCommand.mjs"),
|
||||
);
|
||||
if (kind === "directory") await mkdir(join(root, ".git"));
|
||||
else if (kind === "worktree file")
|
||||
await writeFile(join(root, ".git"), "gitdir: /fixture/worktree");
|
||||
const bin = await fakeRunner(
|
||||
"process.stdout.write(JSON.stringify(process.argv.slice(2)));",
|
||||
);
|
||||
const { child, closed } = start(bin, "6.4.0", entry);
|
||||
child.stdin.end();
|
||||
expect(await closed).toMatchObject({
|
||||
code: 0,
|
||||
stdout: '["-y","rea-agents@6.4.0","mcp"]',
|
||||
stderr: "",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("uses the matching installed runtime without another package-runner install", async () => {
|
||||
const { root, entry } = await pluginFixture();
|
||||
const { child, closed } = start(root, "6.4.0", entry);
|
||||
child.stdin.end();
|
||||
expect(await closed).toMatchObject({
|
||||
code: 0,
|
||||
stdout: "bundled-runtime",
|
||||
stderr: "",
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,28 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
|
||||
import { GenericJson } from "release-please/build/src/updaters/generic-json.js";
|
||||
import { Version } from "release-please/build/src/version.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { SUPPORTED_NODE_VERSION_RANGE } from "../../src/domain/runtimeVersion.js";
|
||||
import { MCP_STARTUP_POLICY } from "../../src/mcpStartupPolicy.js";
|
||||
|
||||
const readJson = async (path: string): Promise<unknown> =>
|
||||
JSON.parse(await readFile(path, "utf8")) as unknown;
|
||||
|
||||
const versionedDocumentation = ["docs/installation.md"] as const;
|
||||
// Each file lists a fragment of every example Release Please must keep
|
||||
// current, so a lost marker is caught as well as a stale version.
|
||||
const versionedDocumentation = [
|
||||
{
|
||||
path: "docs/installation.md",
|
||||
examples: ["doctor --provider ghidra --json", '"command": "npx"'],
|
||||
},
|
||||
] as const;
|
||||
const pluginManifests = [
|
||||
".claude-plugin/plugin.json",
|
||||
".codex-plugin/plugin.json",
|
||||
] as const;
|
||||
const pluginServerConfig = ".mcp.json";
|
||||
|
||||
describe("release configuration", () => {
|
||||
it("keeps doctor and setup runtime support aligned with package engines", async () => {
|
||||
@@ -24,7 +39,7 @@ describe("release configuration", () => {
|
||||
packages: {
|
||||
".": {
|
||||
"extra-files": [
|
||||
...versionedDocumentation.map((path) => ({
|
||||
...versionedDocumentation.map(({ path }) => ({
|
||||
type: "generic",
|
||||
path,
|
||||
})),
|
||||
@@ -42,6 +57,35 @@ describe("release configuration", () => {
|
||||
path: "server.json",
|
||||
jsonpath: "$.packages[0].version",
|
||||
},
|
||||
...pluginManifests.flatMap((path) => [
|
||||
{
|
||||
type: "json",
|
||||
path,
|
||||
jsonpath: "$.version",
|
||||
},
|
||||
...(path.startsWith(".claude")
|
||||
? [
|
||||
{
|
||||
type: "json",
|
||||
path,
|
||||
jsonpath: "$.mcpServers.rea.args[1]",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]),
|
||||
{
|
||||
type: "json",
|
||||
path: pluginServerConfig,
|
||||
jsonpath: "$.mcpServers.rea.args[1]",
|
||||
},
|
||||
...[
|
||||
".claude-plugin/marketplace.json",
|
||||
".agents/plugins/marketplace.json",
|
||||
].map((path) => ({
|
||||
type: "json",
|
||||
path,
|
||||
jsonpath: "$.plugins[0].source.version",
|
||||
})),
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -49,18 +93,27 @@ describe("release configuration", () => {
|
||||
});
|
||||
|
||||
it.each(versionedDocumentation)(
|
||||
"marks the versioned package example in %s",
|
||||
async (path) => {
|
||||
"marks every versioned package example in $path",
|
||||
async ({ path, examples }) => {
|
||||
const content = await readFile(path, "utf8");
|
||||
const packageJson = (await readJson("package.json")) as {
|
||||
version: string;
|
||||
};
|
||||
const versionBlock =
|
||||
/<!-- x-release-please-start-version -->[\s\S]*?<!-- x-release-please-end -->/u.exec(
|
||||
content,
|
||||
)?.[0];
|
||||
const versionBlocks = [
|
||||
...content.matchAll(
|
||||
/<!-- x-release-please-start-version -->[\s\S]*?<!-- x-release-please-end -->/gu,
|
||||
),
|
||||
].map(([block]) => block);
|
||||
|
||||
expect(versionBlock).toContain(`rea-agents@${packageJson.version}`);
|
||||
for (const example of examples)
|
||||
expect(
|
||||
versionBlocks.filter((block) => block.includes(example)),
|
||||
example,
|
||||
).toHaveLength(1);
|
||||
for (const block of versionBlocks)
|
||||
expect(block.match(/rea-agents@[^\s"`]+/gu)).toEqual([
|
||||
`rea-agents@${packageJson.version}`,
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -71,10 +124,13 @@ describe("release configuration", () => {
|
||||
expect(await readJson(".release-please-manifest.json")).toEqual({
|
||||
".": packageJson.version,
|
||||
});
|
||||
expect(await readJson("package-lock.json")).toMatchObject({
|
||||
expect(await readJson("npm-shrinkwrap.json")).toMatchObject({
|
||||
version: packageJson.version,
|
||||
packages: { "": { version: packageJson.version } },
|
||||
});
|
||||
await expect(access("package-lock.json")).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
const changelog = await readFile("CHANGELOG.md", "utf8");
|
||||
expect(/^## \[([^\]]+)\]/mu.exec(changelog)?.[1]).toBe(packageJson.version);
|
||||
});
|
||||
@@ -120,3 +176,189 @@ describe("release configuration", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin manifests", () => {
|
||||
it("distributes both plugins with generated canonical skills from the exact npm release", async () => {
|
||||
const pkg = (await readJson("package.json")) as {
|
||||
version: string;
|
||||
files: string[];
|
||||
};
|
||||
expect(pkg.files).toEqual(
|
||||
expect.arrayContaining([
|
||||
...pluginManifests,
|
||||
pluginServerConfig,
|
||||
"skills",
|
||||
]),
|
||||
);
|
||||
for (const path of pluginManifests)
|
||||
expect(await readJson(path)).toMatchObject({ skills: "./skills" });
|
||||
for (const path of [
|
||||
".claude-plugin/marketplace.json",
|
||||
".agents/plugins/marketplace.json",
|
||||
])
|
||||
expect(await readJson(path)).toMatchObject({
|
||||
plugins: [
|
||||
{
|
||||
source: {
|
||||
source: "npm",
|
||||
package: "rea-agents",
|
||||
version: pkg.version,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it.each(pluginManifests)(
|
||||
"pins the plugin manifest %s and Codex's server config path",
|
||||
async (path) => {
|
||||
const packageJson = (await readJson("package.json")) as {
|
||||
name: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
expect(await readJson(path)).toMatchObject({
|
||||
version: packageJson.version,
|
||||
...(path.startsWith(".codex")
|
||||
? { mcpServers: `./${pluginServerConfig}` }
|
||||
: {}),
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("plugin release pins", () => {
|
||||
it("advances every plugin pin through Release Please JSON extra-files", async () => {
|
||||
const packageJson = (await readJson("package.json")) as {
|
||||
version: string;
|
||||
};
|
||||
const currentVersion = Version.parse(packageJson.version);
|
||||
const nextVersion = `${currentVersion.major}.${currentVersion.minor + 1}.0`;
|
||||
const version = Version.parse(nextVersion);
|
||||
const config = (await readJson("release-please-config.json")) as {
|
||||
packages: {
|
||||
".": {
|
||||
"extra-files": Array<{
|
||||
type: string;
|
||||
path: string;
|
||||
jsonpath?: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
const updated = new Map<string, string>();
|
||||
|
||||
for (const extraFile of config.packages["."]["extra-files"]) {
|
||||
if (extraFile.type !== "json" || extraFile.jsonpath === undefined)
|
||||
continue;
|
||||
const content =
|
||||
updated.get(extraFile.path) ?? (await readFile(extraFile.path, "utf8"));
|
||||
updated.set(
|
||||
extraFile.path,
|
||||
new GenericJson(extraFile.jsonpath, version).updateContent(content),
|
||||
);
|
||||
}
|
||||
|
||||
const updatedJson = new Map<string, unknown>(
|
||||
[...updated].map(
|
||||
([path, content]) => [path, JSON.parse(content) as unknown] as const,
|
||||
),
|
||||
);
|
||||
expect(updatedJson.get(".claude-plugin/plugin.json")).toMatchObject({
|
||||
version: nextVersion,
|
||||
mcpServers: {
|
||||
rea: {
|
||||
args: [
|
||||
"${CLAUDE_PLUGIN_ROOT}/scripts/plugin-mcp-launcher.mjs",
|
||||
nextVersion,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(updatedJson.get(".codex-plugin/plugin.json")).toMatchObject({
|
||||
version: nextVersion,
|
||||
});
|
||||
expect(updatedJson.get(pluginServerConfig)).toMatchObject({
|
||||
mcpServers: {
|
||||
rea: {
|
||||
args: ["./scripts/plugin-mcp-launcher.mjs", nextVersion],
|
||||
startup_timeout_sec: 30,
|
||||
},
|
||||
},
|
||||
});
|
||||
for (const path of [
|
||||
".claude-plugin/marketplace.json",
|
||||
".agents/plugins/marketplace.json",
|
||||
])
|
||||
expect(updatedJson.get(path)).toMatchObject({
|
||||
plugins: [
|
||||
{
|
||||
source: {
|
||||
source: "npm",
|
||||
package: "rea-agents",
|
||||
version: nextVersion,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("pins the plugin server launch from the shared MCP declaration", async () => {
|
||||
const packageJson = (await readJson("package.json")) as {
|
||||
version: string;
|
||||
};
|
||||
|
||||
expect(await readJson(pluginServerConfig)).toMatchObject({
|
||||
mcpServers: {
|
||||
rea: {
|
||||
command: "node",
|
||||
args: ["./scripts/plugin-mcp-launcher.mjs", packageJson.version],
|
||||
cwd: ".",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each(pluginManifests)(
|
||||
"stores %s in the form Release Please rewrites it to",
|
||||
async (path) => {
|
||||
// Release Please reserializes JSON extra-files, so any other layout
|
||||
// would change formatting in every release PR.
|
||||
const content = await readFile(path, "utf8");
|
||||
|
||||
expect(content).toBe(`${JSON.stringify(JSON.parse(content), null, 2)}
|
||||
`);
|
||||
},
|
||||
);
|
||||
|
||||
it("declares the interface fields Codex plugin validation requires", async () => {
|
||||
const manifest = (await readJson(".codex-plugin/plugin.json")) as {
|
||||
interface: Record<string, unknown>;
|
||||
};
|
||||
|
||||
for (const field of [
|
||||
"displayName",
|
||||
"shortDescription",
|
||||
"longDescription",
|
||||
"developerName",
|
||||
"category",
|
||||
])
|
||||
expect(manifest.interface[field]).toEqual(expect.stringMatching(/\S/u));
|
||||
expect(manifest.interface.capabilities).toEqual(
|
||||
expect.arrayContaining([expect.stringMatching(/\S/u)]),
|
||||
);
|
||||
expect(manifest.interface.defaultPrompt).toEqual(
|
||||
expect.arrayContaining([expect.stringMatching(/\S/u)]),
|
||||
);
|
||||
});
|
||||
|
||||
it("carries the owned Codex startup timeout in the companion server entry", async () => {
|
||||
expect(await readJson(pluginServerConfig)).toMatchObject({
|
||||
mcpServers: {
|
||||
rea: {
|
||||
startup_timeout_sec: MCP_STARTUP_POLICY.codexStartupTimeoutSeconds,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+2
-1
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"include": ["src/**/*.ts", "src/**/*.mjs"],
|
||||
"exclude": [
|
||||
"src/**/*.fixture.ts",
|
||||
"src/**/*.test.ts",
|
||||
|
||||
+19
-19
@@ -9,12 +9,12 @@
|
||||
"dependsOn": ["metadata:check"],
|
||||
"inputs": [
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"npm-shrinkwrap.json",
|
||||
"scripts/check-dependency-install.mjs",
|
||||
"scripts/clean-build-output.mjs",
|
||||
"scripts/bundle-runtime.mjs",
|
||||
"scripts/generate-package-metadata.mjs",
|
||||
"src/**/*.ts",
|
||||
"src/**/*.{ts,mjs,mts}",
|
||||
"!src/**/*.test.ts",
|
||||
"!src/**/*.fixture.ts",
|
||||
"tsconfig.json",
|
||||
@@ -25,8 +25,8 @@
|
||||
"typecheck": {
|
||||
"inputs": [
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"src/**/*.ts",
|
||||
"npm-shrinkwrap.json",
|
||||
"src/**/*.{ts,mjs,mts}",
|
||||
"tests/**/*.ts",
|
||||
"tsconfig.json",
|
||||
"tsconfig.build.json",
|
||||
@@ -39,7 +39,7 @@
|
||||
"metadata:check": {
|
||||
"inputs": [
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"npm-shrinkwrap.json",
|
||||
"scripts/generate-package-metadata.mjs",
|
||||
"scripts/lib/generated-file.mjs",
|
||||
".agents/skills/reverse-engineer-anything/SKILL.md",
|
||||
@@ -52,7 +52,7 @@
|
||||
".gitignore",
|
||||
".oxlintrc.json",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"npm-shrinkwrap.json",
|
||||
"scripts/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
|
||||
"src/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
|
||||
"tests/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
|
||||
@@ -73,7 +73,7 @@
|
||||
".gitignore",
|
||||
".oxlintrc.json",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"npm-shrinkwrap.json",
|
||||
"scripts/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
|
||||
"src/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
|
||||
"tests/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
|
||||
@@ -94,9 +94,9 @@
|
||||
".gitignore",
|
||||
"knip.json",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"npm-shrinkwrap.json",
|
||||
"scripts/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
|
||||
"src/**/*.ts",
|
||||
"src/**/*.{ts,mjs,mts}",
|
||||
"tests/**/*.ts",
|
||||
"tsconfig.json",
|
||||
"tsconfig.build.json",
|
||||
@@ -109,7 +109,7 @@
|
||||
".gitignore",
|
||||
".jscpd.json",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"npm-shrinkwrap.json",
|
||||
"scripts/ci/static-check-report.mjs",
|
||||
"src/**",
|
||||
"tests/**"
|
||||
@@ -120,7 +120,7 @@
|
||||
"inputs": [
|
||||
"bridge/**/*.{java,py}",
|
||||
"scripts/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
|
||||
"src/**/*.ts",
|
||||
"src/**/*.{ts,mjs,mts}",
|
||||
"tests/**/*.ts"
|
||||
]
|
||||
},
|
||||
@@ -141,7 +141,7 @@
|
||||
],
|
||||
"inputs": [
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"npm-shrinkwrap.json",
|
||||
"scripts/generate-*.mjs",
|
||||
"scripts/check-doc-facts.mjs",
|
||||
"scripts/lib/*.mjs",
|
||||
@@ -172,7 +172,7 @@
|
||||
"dependsOn": ["compile"],
|
||||
"inputs": [
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"npm-shrinkwrap.json",
|
||||
"scripts/generate-mcp-tool-catalog.mjs",
|
||||
"scripts/lib/generated-file.mjs"
|
||||
],
|
||||
@@ -182,7 +182,7 @@
|
||||
"dependsOn": ["compile"],
|
||||
"inputs": [
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"npm-shrinkwrap.json",
|
||||
"scripts/generate-skill-metadata.mjs",
|
||||
"scripts/lib/generated-file.mjs",
|
||||
".agents/skills/reverse-engineer-anything/**"
|
||||
@@ -193,7 +193,7 @@
|
||||
"dependsOn": ["compile"],
|
||||
"inputs": [
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"npm-shrinkwrap.json",
|
||||
"scripts/generate-product-catalog.mjs",
|
||||
"scripts/lib/**/*.mjs",
|
||||
".oxfmtrc.json"
|
||||
@@ -204,7 +204,7 @@
|
||||
"dependsOn": ["artifacts:skills"],
|
||||
"inputs": [
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"npm-shrinkwrap.json",
|
||||
"scripts/generate-completion-ledger.mjs",
|
||||
"scripts/lib/generated-file.mjs",
|
||||
"scripts/verify/managed/**",
|
||||
@@ -219,7 +219,7 @@
|
||||
"dependsOn": ["compile"],
|
||||
"inputs": [
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"npm-shrinkwrap.json",
|
||||
"scripts/generate-error-schema.mjs",
|
||||
"scripts/lib/generated-file.mjs",
|
||||
"scripts/lib/format-generated-file.mjs",
|
||||
@@ -231,7 +231,7 @@
|
||||
"dependsOn": ["artifacts:mcp-catalog"],
|
||||
"inputs": [
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"npm-shrinkwrap.json",
|
||||
"scripts/generate-mcp-tool-catalog.mjs",
|
||||
"scripts/lib/generated-file.mjs",
|
||||
{
|
||||
@@ -244,7 +244,7 @@
|
||||
"dependsOn": ["artifacts:managed-evidence"],
|
||||
"inputs": [
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"npm-shrinkwrap.json",
|
||||
"scripts/generate-completion-ledger.mjs",
|
||||
"scripts/lib/generated-file.mjs",
|
||||
"scripts/verify/managed/**",
|
||||
|
||||
Reference in New Issue
Block a user