Merge pull request #2863 from affaan-m/maint/release-2.2-ready

fix(release): make ECC 2.2 ready to publish
This commit is contained in:
Affaan Mustafa
2026-08-27 13:00:06 -04:00
committed by GitHub
70 changed files with 3215 additions and 302 deletions
+78 -34
View File
@@ -14,6 +14,9 @@ jobs:
outputs:
already_published: ${{ steps.npm_publish_state.outputs.already_published }}
dist_tag: ${{ steps.npm_publish_state.outputs.dist_tag }}
publish_tag: ${{ steps.npm_publish_state.outputs.publish_tag }}
package_name: ${{ steps.npm_publish_state.outputs.package_name }}
package_version: ${{ steps.npm_publish_state.outputs.package_version }}
package_file: ${{ steps.pack.outputs.package_file }}
package_sha256: ${{ steps.pack.outputs.package_sha256 }}
@@ -24,6 +27,16 @@ jobs:
fetch-depth: 0
persist-credentials: false
- name: Require the release commit to equal origin main
run: |
git fetch origin main --no-tags
RELEASE_COMMIT=$(git rev-parse HEAD)
MAIN_COMMIT=$(git rev-parse origin/main)
if [ "$RELEASE_COMMIT" != "$MAIN_COMMIT" ]; then
echo "::error::The release commit must equal origin/main exactly"
exit 1
fi
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
@@ -69,43 +82,42 @@ jobs:
PACKAGE_NAME=$(node -p "require('./package.json').name")
PACKAGE_VERSION=$(node -p "require('./package.json').version")
NPM_DIST_TAG=$(node -p "require('./package.json').version.includes('-') ? 'next' : 'latest'")
if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
NPM_PUBLISH_TAG=$(node -p "require('./package.json').version.includes('-') ? 'next' : 'staged'")
set +e
NPM_LOOKUP=$(npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version 2>&1)
NPM_STATUS=$?
set -e
if [ "$NPM_STATUS" -eq 0 ]; then
echo "already_published=true" >> "$GITHUB_OUTPUT"
else
elif printf '%s\n' "$NPM_LOOKUP" | grep -q 'E404'; then
echo "already_published=false" >> "$GITHUB_OUTPUT"
else
echo "::error::npm registry lookup failed; refusing to infer that the version is unpublished"
printf '%s\n' "$NPM_LOOKUP"
exit "$NPM_STATUS"
fi
echo "package_name=${PACKAGE_NAME}" >> "$GITHUB_OUTPUT"
echo "package_version=${PACKAGE_VERSION}" >> "$GITHUB_OUTPUT"
echo "dist_tag=${NPM_DIST_TAG}" >> "$GITHUB_OUTPUT"
echo "publish_tag=${NPM_PUBLISH_TAG}" >> "$GITHUB_OUTPUT"
- name: Generate release highlights
id: highlights
- name: Use reviewed release notes
env:
TAG_NAME: ${{ github.ref_name }}
RELEASE_TAG: ${{ github.ref_name }}
run: |
TAG_VERSION="${TAG_NAME#v}"
cat > release_body.md <<EOF
## ECC ${TAG_VERSION}
### What This Release Focuses On
- Harness reliability and hook stability across Claude Code, Cursor, OpenCode, and Codex
- Stronger eval-driven workflows and quality gates
- Better operator UX for autonomous loop execution
### Notable Changes
- Session persistence and hook lifecycle fixes
- Expanded skills and command coverage for harness performance work
- Improved release-note generation and changelog hygiene
### Notes
- npm package: \`ecc-universal\`
- Claude marketplace/plugin identifier: \`ecc@ecc\`
- For migration tips and compatibility notes, see README and CHANGELOG.
EOF
RELEASE_VERSION="${RELEASE_TAG#v}"
RELEASE_NOTES="docs/releases/${RELEASE_VERSION}/release-notes.md"
if [ ! -f "$RELEASE_NOTES" ]; then
echo "::error::Missing reviewed release notes for ${RELEASE_VERSION}: ${RELEASE_NOTES}"
exit 1
fi
cp "$RELEASE_NOTES" release_body.md
- name: Pack npm artifact
id: pack
run: |
npm pack --json > npm-pack.json
node -e "const crypto = require('crypto'); const fs = require('fs'); const data = JSON.parse(fs.readFileSync('npm-pack.json', 'utf8')); const file = data[0]?.filename; if (!/^ecc-universal-[0-9A-Za-z.+-]+\.tgz$/.test(file || '')) throw new Error('Unexpected packed filename'); const archives = fs.readdirSync('.').filter(name => name.endsWith('.tgz')); if (archives.length !== 1 || archives[0] !== file) throw new Error('Expected exactly one packed archive'); const digest = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); fs.appendFileSync(process.env.GITHUB_OUTPUT, 'package_file=' + file + '\npackage_sha256=' + digest + '\n')"
node -e "const crypto = require('crypto'); const fs = require('fs'); const data = JSON.parse(fs.readFileSync('npm-pack.json', 'utf8')); const entries = Array.isArray(data) ? data : [data]; const file = entries.find(entry => /^ecc-universal-[0-9A-Za-z.+-]+\.tgz$/.test(entry?.filename || ''))?.filename; if (!file) throw new Error('Unexpected packed filename'); const archives = fs.readdirSync('.').filter(name => name.endsWith('.tgz')); if (archives.length !== 1 || archives[0] !== file) throw new Error('Expected exactly one packed archive'); const digest = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); fs.appendFileSync(process.env.GITHUB_OUTPUT, 'package_file=' + file + '\npackage_sha256=' + digest + '\n')"
- name: Upload release artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -182,18 +194,50 @@ jobs:
ECC_RELEASE_SHA256: ${{ needs.verify.outputs.package_sha256 }}
run: node -e "const crypto = require('crypto'); const fs = require('fs'); const file = process.env.ECC_RELEASE_PACKAGE; const expected = process.env.ECC_RELEASE_SHA256; if (!/^ecc-universal-[0-9A-Za-z.+-]+\.tgz$/.test(file || '')) throw new Error('Unexpected packed filename'); if (!/^[a-f0-9]{64}$/.test(expected || '')) throw new Error('Invalid packed SHA-256'); const archives = fs.readdirSync('.').filter(name => name.endsWith('.tgz')); if (archives.length !== 1 || archives[0] !== file) throw new Error('Expected exactly one downloaded archive'); const actual = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); if (actual !== expected) throw new Error('Downloaded publish artifact SHA-256 mismatch')"
- name: Create GitHub Release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
body_path: release_body.md
generate_release_notes: true
prerelease: ${{ contains(github.ref_name, '-') }}
make_latest: ${{ contains(github.ref_name, '-') && 'false' || 'true' }}
- name: Publish npm package
if: needs.verify.outputs.already_published != 'true'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
ECC_RELEASE_PACKAGE: ${{ needs.verify.outputs.package_file }}
NPM_PUBLISH_TAG: ${{ needs.verify.outputs.publish_tag }}
run: npm publish "./${ECC_RELEASE_PACKAGE}" --access public --provenance --tag "${NPM_PUBLISH_TAG}"
- name: Verify published npm artifact
env:
ECC_RELEASE_PACKAGE: ${{ needs.verify.outputs.package_file }}
PACKAGE_NAME: ${{ needs.verify.outputs.package_name }}
PACKAGE_VERSION: ${{ needs.verify.outputs.package_version }}
run: |
REGISTRY_INTEGRITY=""
for ATTEMPT in 1 2 3 4 5 6; do
set +e
REGISTRY_INTEGRITY=$(npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" dist.integrity 2>&1)
NPM_STATUS=$?
set -e
if [ "$NPM_STATUS" -eq 0 ]; then
break
fi
if [ "$ATTEMPT" -eq 6 ]; then
echo "::error::Published npm artifact was not readable after six attempts"
printf '%s\n' "$REGISTRY_INTEGRITY"
exit "$NPM_STATUS"
fi
sleep 5
done
ECC_REGISTRY_INTEGRITY="$REGISTRY_INTEGRITY" node -e "const crypto = require('crypto'); const fs = require('fs'); const expected = process.env.ECC_REGISTRY_INTEGRITY; if (!/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(expected || '')) throw new Error('Invalid published registry integrity'); const actual = 'sha512-' + crypto.createHash('sha512').update(fs.readFileSync(process.env.ECC_RELEASE_PACKAGE)).digest('base64'); if (actual !== expected) throw new Error('Published npm artifact does not match tested candidate')"
- name: Promote verified npm version
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
PACKAGE_NAME: ${{ needs.verify.outputs.package_name }}
PACKAGE_VERSION: ${{ needs.verify.outputs.package_version }}
NPM_DIST_TAG: ${{ needs.verify.outputs.dist_tag }}
run: npm publish "./${ECC_RELEASE_PACKAGE}" --access public --provenance --tag "${NPM_DIST_TAG}"
run: npm dist-tag add "${PACKAGE_NAME}@${PACKAGE_VERSION}" "${NPM_DIST_TAG}"
- name: Create GitHub Release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
body_path: release_body.md
generate_release_notes: false
prerelease: ${{ contains(github.ref_name, '-') }}
make_latest: ${{ contains(github.ref_name, '-') && 'false' || 'true' }}
+79 -38
View File
@@ -7,11 +7,6 @@ on:
description: 'Version tag (e.g., v1.0.0)'
required: true
type: string
generate-notes:
description: 'Auto-generate release notes'
required: false
type: boolean
default: true
secrets:
NPM_TOKEN:
required: false
@@ -21,11 +16,6 @@ on:
description: 'Version tag to release or republish (e.g., v2.0.0-rc.1)'
required: true
type: string
generate-notes:
description: 'Auto-generate release notes'
required: false
type: boolean
default: true
permissions:
contents: read
@@ -37,6 +27,9 @@ jobs:
outputs:
already_published: ${{ steps.npm_publish_state.outputs.already_published }}
dist_tag: ${{ steps.npm_publish_state.outputs.dist_tag }}
publish_tag: ${{ steps.npm_publish_state.outputs.publish_tag }}
package_name: ${{ steps.npm_publish_state.outputs.package_name }}
package_version: ${{ steps.npm_publish_state.outputs.package_version }}
package_file: ${{ steps.pack.outputs.package_file }}
package_sha256: ${{ steps.pack.outputs.package_sha256 }}
@@ -48,6 +41,16 @@ jobs:
ref: refs/tags/${{ inputs.tag }}
persist-credentials: false
- name: Require the release commit to equal origin main
run: |
git fetch origin main --no-tags
RELEASE_COMMIT=$(git rev-parse HEAD)
MAIN_COMMIT=$(git rev-parse origin/main)
if [ "$RELEASE_COMMIT" != "$MAIN_COMMIT" ]; then
echo "::error::The release commit must equal origin/main exactly"
exit 1
fi
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
@@ -93,36 +96,42 @@ jobs:
PACKAGE_NAME=$(node -p "require('./package.json').name")
PACKAGE_VERSION=$(node -p "require('./package.json').version")
NPM_DIST_TAG=$(node -p "require('./package.json').version.includes('-') ? 'next' : 'latest'")
if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
NPM_PUBLISH_TAG=$(node -p "require('./package.json').version.includes('-') ? 'next' : 'staged'")
set +e
NPM_LOOKUP=$(npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version 2>&1)
NPM_STATUS=$?
set -e
if [ "$NPM_STATUS" -eq 0 ]; then
echo "already_published=true" >> "$GITHUB_OUTPUT"
else
elif printf '%s\n' "$NPM_LOOKUP" | grep -q 'E404'; then
echo "already_published=false" >> "$GITHUB_OUTPUT"
else
echo "::error::npm registry lookup failed; refusing to infer that the version is unpublished"
printf '%s\n' "$NPM_LOOKUP"
exit "$NPM_STATUS"
fi
echo "package_name=${PACKAGE_NAME}" >> "$GITHUB_OUTPUT"
echo "package_version=${PACKAGE_VERSION}" >> "$GITHUB_OUTPUT"
echo "dist_tag=${NPM_DIST_TAG}" >> "$GITHUB_OUTPUT"
echo "publish_tag=${NPM_PUBLISH_TAG}" >> "$GITHUB_OUTPUT"
- name: Generate release highlights
- name: Use reviewed release notes
env:
TAG_NAME: ${{ inputs.tag }}
RELEASE_TAG: ${{ inputs.tag }}
run: |
TAG_VERSION="${TAG_NAME#v}"
cat > release_body.md <<EOF
## ECC ${TAG_VERSION}
### What This Release Focuses On
- Harness reliability and cross-platform compatibility
- Eval-driven quality improvements
- Better workflow and operator ergonomics
### Package Notes
- npm package: \`ecc-universal\`
- Claude marketplace/plugin identifier: \`ecc@ecc\`
EOF
RELEASE_VERSION="${RELEASE_TAG#v}"
RELEASE_NOTES="docs/releases/${RELEASE_VERSION}/release-notes.md"
if [ ! -f "$RELEASE_NOTES" ]; then
echo "::error::Missing reviewed release notes for ${RELEASE_VERSION}: ${RELEASE_NOTES}"
exit 1
fi
cp "$RELEASE_NOTES" release_body.md
- name: Pack npm artifact
id: pack
run: |
npm pack --json > npm-pack.json
node -e "const crypto = require('crypto'); const fs = require('fs'); const data = JSON.parse(fs.readFileSync('npm-pack.json', 'utf8')); const file = data[0]?.filename; if (!/^ecc-universal-[0-9A-Za-z.+-]+\.tgz$/.test(file || '')) throw new Error('Unexpected packed filename'); const archives = fs.readdirSync('.').filter(name => name.endsWith('.tgz')); if (archives.length !== 1 || archives[0] !== file) throw new Error('Expected exactly one packed archive'); const digest = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); fs.appendFileSync(process.env.GITHUB_OUTPUT, 'package_file=' + file + '\npackage_sha256=' + digest + '\n')"
node -e "const crypto = require('crypto'); const fs = require('fs'); const data = JSON.parse(fs.readFileSync('npm-pack.json', 'utf8')); const entries = Array.isArray(data) ? data : [data]; const file = entries.find(entry => /^ecc-universal-[0-9A-Za-z.+-]+\.tgz$/.test(entry?.filename || ''))?.filename; if (!file) throw new Error('Unexpected packed filename'); const archives = fs.readdirSync('.').filter(name => name.endsWith('.tgz')); if (archives.length !== 1 || archives[0] !== file) throw new Error('Expected exactly one packed archive'); const digest = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); fs.appendFileSync(process.env.GITHUB_OUTPUT, 'package_file=' + file + '\npackage_sha256=' + digest + '\n')"
- name: Upload release artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -199,19 +208,51 @@ jobs:
ECC_RELEASE_SHA256: ${{ needs.verify.outputs.package_sha256 }}
run: node -e "const crypto = require('crypto'); const fs = require('fs'); const file = process.env.ECC_RELEASE_PACKAGE; const expected = process.env.ECC_RELEASE_SHA256; if (!/^ecc-universal-[0-9A-Za-z.+-]+\.tgz$/.test(file || '')) throw new Error('Unexpected packed filename'); if (!/^[a-f0-9]{64}$/.test(expected || '')) throw new Error('Invalid packed SHA-256'); const archives = fs.readdirSync('.').filter(name => name.endsWith('.tgz')); if (archives.length !== 1 || archives[0] !== file) throw new Error('Expected exactly one downloaded archive'); const actual = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); if (actual !== expected) throw new Error('Downloaded publish artifact SHA-256 mismatch')"
- name: Create GitHub Release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
tag_name: ${{ inputs.tag }}
body_path: release_body.md
generate_release_notes: ${{ inputs.generate-notes }}
prerelease: ${{ contains(inputs.tag, '-') }}
make_latest: ${{ contains(inputs.tag, '-') && 'false' || 'true' }}
- name: Publish npm package
if: needs.verify.outputs.already_published != 'true'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
ECC_RELEASE_PACKAGE: ${{ needs.verify.outputs.package_file }}
NPM_PUBLISH_TAG: ${{ needs.verify.outputs.publish_tag }}
run: npm publish "./${ECC_RELEASE_PACKAGE}" --access public --provenance --tag "${NPM_PUBLISH_TAG}"
- name: Verify published npm artifact
env:
ECC_RELEASE_PACKAGE: ${{ needs.verify.outputs.package_file }}
PACKAGE_NAME: ${{ needs.verify.outputs.package_name }}
PACKAGE_VERSION: ${{ needs.verify.outputs.package_version }}
run: |
REGISTRY_INTEGRITY=""
for ATTEMPT in 1 2 3 4 5 6; do
set +e
REGISTRY_INTEGRITY=$(npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" dist.integrity 2>&1)
NPM_STATUS=$?
set -e
if [ "$NPM_STATUS" -eq 0 ]; then
break
fi
if [ "$ATTEMPT" -eq 6 ]; then
echo "::error::Published npm artifact was not readable after six attempts"
printf '%s\n' "$REGISTRY_INTEGRITY"
exit "$NPM_STATUS"
fi
sleep 5
done
ECC_REGISTRY_INTEGRITY="$REGISTRY_INTEGRITY" node -e "const crypto = require('crypto'); const fs = require('fs'); const expected = process.env.ECC_REGISTRY_INTEGRITY; if (!/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(expected || '')) throw new Error('Invalid published registry integrity'); const actual = 'sha512-' + crypto.createHash('sha512').update(fs.readFileSync(process.env.ECC_RELEASE_PACKAGE)).digest('base64'); if (actual !== expected) throw new Error('Published npm artifact does not match tested candidate')"
- name: Promote verified npm version
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
PACKAGE_NAME: ${{ needs.verify.outputs.package_name }}
PACKAGE_VERSION: ${{ needs.verify.outputs.package_version }}
NPM_DIST_TAG: ${{ needs.verify.outputs.dist_tag }}
run: npm publish "./${ECC_RELEASE_PACKAGE}" --access public --provenance --tag "${NPM_DIST_TAG}"
run: npm dist-tag add "${PACKAGE_NAME}@${PACKAGE_VERSION}" "${NPM_DIST_TAG}"
- name: Create GitHub Release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
tag_name: ${{ inputs.tag }}
body_path: release_body.md
generate_release_notes: false
prerelease: ${{ contains(inputs.tag, '-') }}
make_latest: ${{ contains(inputs.tag, '-') && 'false' || 'true' }}
+4 -2
View File
@@ -224,8 +224,6 @@ Full configuration in `opencode.json`:
```json
{
"$schema": "https://opencode.ai/config.json",
"model": "anthropic/claude-sonnet-4-5",
"small_model": "anthropic/claude-haiku-4-5",
"plugin": ["./plugins"],
"instructions": [
"skills/tdd-workflow/SKILL.md",
@@ -236,6 +234,10 @@ Full configuration in `opencode.json`:
}
```
The reference config intentionally leaves model selection to OpenCode. Connect a
provider and select a model in OpenCode; ECC's primary agent uses that global
selection, and its subagents inherit the invoking primary agent's model.
## License
MIT
-28
View File
@@ -1,7 +1,5 @@
{
"$schema": "https://opencode.ai/config.json",
"model": "anthropic/claude-sonnet-4-5",
"small_model": "anthropic/claude-haiku-4-5",
"default_agent": "build",
"instructions": [
"AGENTS.md",
@@ -31,7 +29,6 @@
"build": {
"description": "Primary coding agent for development work",
"mode": "primary",
"model": "anthropic/claude-sonnet-4-5",
"tools": {
"write": true,
"edit": true,
@@ -43,7 +40,6 @@
"planner": {
"description": "Expert planning specialist for complex features and refactoring. Use for implementation planning, architectural changes, or complex refactoring.",
"mode": "subagent",
"model": "anthropic/claude-opus-4-5",
"prompt": "{file:prompts/agents/planner.txt}",
"tools": {
"read": true,
@@ -55,7 +51,6 @@
"architect": {
"description": "Software architecture specialist for system design, scalability, and technical decision-making.",
"mode": "subagent",
"model": "anthropic/claude-opus-4-5",
"prompt": "{file:prompts/agents/architect.txt}",
"tools": {
"read": true,
@@ -67,7 +62,6 @@
"code-reviewer": {
"description": "Expert code review specialist. Reviews code for quality, security, and maintainability. Use immediately after writing or modifying code.",
"mode": "subagent",
"model": "anthropic/claude-opus-4-5",
"prompt": "{file:prompts/agents/code-reviewer.txt}",
"tools": {
"read": true,
@@ -79,7 +73,6 @@
"security-reviewer": {
"description": "Security vulnerability detection and remediation specialist. Use after writing code that handles user input, authentication, API endpoints, or sensitive data.",
"mode": "subagent",
"model": "anthropic/claude-opus-4-5",
"prompt": "{file:prompts/agents/security-reviewer.txt}",
"tools": {
"read": true,
@@ -91,7 +84,6 @@
"tdd-guide": {
"description": "Test-Driven Development specialist enforcing write-tests-first methodology. Use when writing new features, fixing bugs, or refactoring code. Ensures 80%+ test coverage.",
"mode": "subagent",
"model": "anthropic/claude-opus-4-5",
"prompt": "{file:prompts/agents/tdd-guide.txt}",
"tools": {
"read": true,
@@ -103,7 +95,6 @@
"build-error-resolver": {
"description": "Build and TypeScript error resolution specialist. Use when build fails or type errors occur. Fixes build/type errors only with minimal diffs.",
"mode": "subagent",
"model": "anthropic/claude-opus-4-5",
"prompt": "{file:prompts/agents/build-error-resolver.txt}",
"tools": {
"read": true,
@@ -115,7 +106,6 @@
"e2e-runner": {
"description": "End-to-end testing specialist using Playwright. Generates, maintains, and runs E2E tests for critical user flows.",
"mode": "subagent",
"model": "anthropic/claude-opus-4-5",
"prompt": "{file:prompts/agents/e2e-runner.txt}",
"tools": {
"read": true,
@@ -127,7 +117,6 @@
"doc-updater": {
"description": "Documentation and codemap specialist. Use for updating codemaps and documentation.",
"mode": "subagent",
"model": "anthropic/claude-opus-4-5",
"prompt": "{file:prompts/agents/doc-updater.txt}",
"tools": {
"read": true,
@@ -139,7 +128,6 @@
"refactor-cleaner": {
"description": "Dead code cleanup and consolidation specialist. Use for removing unused code, duplicates, and refactoring.",
"mode": "subagent",
"model": "anthropic/claude-opus-4-5",
"prompt": "{file:prompts/agents/refactor-cleaner.txt}",
"tools": {
"read": true,
@@ -151,7 +139,6 @@
"go-reviewer": {
"description": "Expert Go code reviewer specializing in idiomatic Go, concurrency patterns, error handling, and performance.",
"mode": "subagent",
"model": "anthropic/claude-opus-4-5",
"prompt": "{file:prompts/agents/go-reviewer.txt}",
"tools": {
"read": true,
@@ -163,7 +150,6 @@
"go-build-resolver": {
"description": "Go build, vet, and compilation error resolution specialist. Fixes Go build errors with minimal changes.",
"mode": "subagent",
"model": "anthropic/claude-opus-4-5",
"prompt": "{file:prompts/agents/go-build-resolver.txt}",
"tools": {
"read": true,
@@ -175,7 +161,6 @@
"database-reviewer": {
"description": "PostgreSQL database specialist for query optimization, schema design, security, and performance. Incorporates Supabase best practices.",
"mode": "subagent",
"model": "anthropic/claude-opus-4-5",
"prompt": "{file:prompts/agents/database-reviewer.txt}",
"tools": {
"read": true,
@@ -187,7 +172,6 @@
"cpp-reviewer": {
"description": "Expert C++ code reviewer specializing in memory safety, modern C++ idioms, concurrency, and performance. Use for all C++ code changes.",
"mode": "subagent",
"model": "anthropic/claude-opus-4-5",
"prompt": "{file:prompts/agents/cpp-reviewer.txt}",
"tools": {
"read": true,
@@ -199,7 +183,6 @@
"cpp-build-resolver": {
"description": "C++ build, CMake, and compilation error resolution specialist. Fixes build errors, linker issues, and template errors with minimal changes.",
"mode": "subagent",
"model": "anthropic/claude-opus-4-5",
"prompt": "{file:prompts/agents/cpp-build-resolver.txt}",
"tools": {
"read": true,
@@ -211,7 +194,6 @@
"docs-lookup": {
"description": "Documentation specialist using Context7 MCP to fetch current library and API documentation with code examples.",
"mode": "subagent",
"model": "anthropic/claude-sonnet-4-5",
"prompt": "{file:prompts/agents/docs-lookup.txt}",
"tools": {
"read": true,
@@ -223,7 +205,6 @@
"harness-optimizer": {
"description": "Analyze and improve the local agent harness configuration for reliability, cost, and throughput.",
"mode": "subagent",
"model": "anthropic/claude-sonnet-4-5",
"prompt": "{file:prompts/agents/harness-optimizer.txt}",
"tools": {
"read": true,
@@ -234,7 +215,6 @@
"java-reviewer": {
"description": "Expert Java and Spring Boot code reviewer specializing in layered architecture, JPA patterns, security, and concurrency.",
"mode": "subagent",
"model": "anthropic/claude-opus-4-5",
"prompt": "{file:prompts/agents/java-reviewer.txt}",
"tools": {
"read": true,
@@ -246,7 +226,6 @@
"java-build-resolver": {
"description": "Java/Maven/Gradle build, compilation, and dependency error resolution specialist. Fixes build errors with minimal changes.",
"mode": "subagent",
"model": "anthropic/claude-opus-4-5",
"prompt": "{file:prompts/agents/java-build-resolver.txt}",
"tools": {
"read": true,
@@ -258,7 +237,6 @@
"kotlin-reviewer": {
"description": "Kotlin and Android/KMP code reviewer. Reviews Kotlin code for idiomatic patterns, coroutine safety, Compose best practices.",
"mode": "subagent",
"model": "anthropic/claude-opus-4-5",
"prompt": "{file:prompts/agents/kotlin-reviewer.txt}",
"tools": {
"read": true,
@@ -270,7 +248,6 @@
"kotlin-build-resolver": {
"description": "Kotlin/Gradle build, compilation, and dependency error resolution specialist. Fixes Kotlin build errors with minimal changes.",
"mode": "subagent",
"model": "anthropic/claude-opus-4-5",
"prompt": "{file:prompts/agents/kotlin-build-resolver.txt}",
"tools": {
"read": true,
@@ -282,7 +259,6 @@
"loop-operator": {
"description": "Operate autonomous agent loops, monitor progress, and intervene safely when loops stall.",
"mode": "subagent",
"model": "anthropic/claude-sonnet-4-5",
"prompt": "{file:prompts/agents/loop-operator.txt}",
"tools": {
"read": true,
@@ -293,7 +269,6 @@
"php-reviewer": {
"description": "Expert PHP code reviewer specializing in PSR-12 compliance, PHP type system, Eloquent ORM patterns, security, and performance.",
"mode": "subagent",
"model": "anthropic/claude-opus-4-5",
"prompt": "{file:prompts/agents/php-reviewer.txt}",
"tools": {
"read": true,
@@ -305,7 +280,6 @@
"python-reviewer": {
"description": "Expert Python code reviewer specializing in PEP 8 compliance, Pythonic idioms, type hints, security, and performance.",
"mode": "subagent",
"model": "anthropic/claude-opus-4-5",
"prompt": "{file:prompts/agents/python-reviewer.txt}",
"tools": {
"read": true,
@@ -317,7 +291,6 @@
"rust-reviewer": {
"description": "Expert Rust code reviewer specializing in idiomatic Rust, ownership, lifetimes, concurrency, and performance.",
"mode": "subagent",
"model": "anthropic/claude-opus-4-5",
"prompt": "{file:prompts/agents/rust-reviewer.txt}",
"tools": {
"read": true,
@@ -329,7 +302,6 @@
"rust-build-resolver": {
"description": "Rust build, Cargo, and compilation error resolution specialist. Fixes Rust build errors with minimal changes.",
"mode": "subagent",
"model": "anthropic/claude-opus-4-5",
"prompt": "{file:prompts/agents/rust-build-resolver.txt}",
"tools": {
"read": true,
+21
View File
@@ -2,13 +2,34 @@
## Unreleased
## 2.2.0 - 2026-08-25
### Added
- Guided, manifest-driven setup across supported harnesses, with exact install-state ownership, health checks, repair, and uninstall workflows.
- Native Antigravity 2.0 installation under `.agents/`, including rules, workflows, skills, and adapted agents, plus a cross-platform installation guide.
- New workflow and operator capabilities including the Itô skill family, an experimental Nasiko CLI lifecycle bridge, multi-model council review, dev-team collaboration, agent evaluation, living-docs governance, secure terminal opening, and TasteForge multimodal workflows.
- A thin Pi adapter and expanded cross-harness support, release artifact lifecycle testing, Docker-based CLI testing, and stronger Python validation.
### Changed
- Default MCP connector set reduced to a single connector (`chrome-devtools`) per the new connector policy (`docs/MCP-CONNECTOR-POLICY.md`). The six previous defaults (`github`, `context7`, `exa`, `memory`, `playwright`, `sequential-thinking`) were retired after the June 2026 audit: their jobs are covered by skills wrapping CLIs/REST APIs (`github-ops`, `documentation-lookup`, `exa-search`, e2e skills) or by harness-native features (memory, extended thinking, web search). All six remain opt-in via `mcp-configs/mcp-servers.json`.
- OpenCode home installs now use its canonical `~/.config/opencode` location, safely discover and migrate unchanged ECC-managed files from legacy `~/.opencode` installs, and preserve modified legacy files for review. Bundled agents inherit the model selected by the user instead of pinning an Anthropic provider.
- `skill-comply` is now part of the install manifest and npm distribution, with generated Python caches excluded from both install and package surfaces.
- Release automation now verifies the tag is exactly on `origin/main`, fails closed on npm registry errors, tests the exact packed artifact across Linux, macOS, and Windows, publishes stable versions to a staging dist-tag, verifies registry bytes before promoting `latest`, creates the GitHub Release after promotion, and uses reviewed release notes.
### Fixed
- `ecc memory` writes and `--body-file` reads failed on Windows under Node 22.12-22.16 and 24.0-24.1. libuv resolved path-based `stat()`/`lstat()` through `GetFileInformationByName` without setting the volume serial, while `fstat()` reported it, so the memory vault's TOCTOU guard rejected every operation. Fixed upstream in libuv 1.51.0; the guard no longer depends on the runtime's patch level. The guard's stat calls now request `BigInt` values, so Windows file IDs past `Number.MAX_SAFE_INTEGER` can no longer collapse two distinct files into one identity.
- Selective reinstall now merges the prior ownership ledger, so later module additions do not orphan files from earlier installs and uninstall removes the complete managed surface.
- Legacy Codex sync uninstall now uses ownership evidence, preserves user files, and requires an explicit opt-in for weaker marker-only cleanup.
- The experimental Nasiko CLI lifecycle bridge now recovers locks only after confirming the recorded owner is dead, preserves replacement locks, strictly rejects malformed tar sizes, padding, terminators, and trailing data, and fails uninstall when staged files remain.
- Hook, plan-canvas, session, memory, observer, skill-evolution, Discord delivery, and Windows compatibility regressions fixed across the runtime.
### Release audit
- Audited the complete delta from `v2.1.0`: 108 commits across 530 files, with 40,299 insertions and 4,679 deletions on the pre-release baseline.
- The release gate installs and exercises the exact npm archive, including cumulative ownership, doctor, drift detection, repair, uninstall, and user-file preservation.
## 2.0.0 - 2026-06-09
+21 -20
View File
@@ -76,8 +76,8 @@ Run these commands inside Claude Code:
That installs ECC's skills, agents, commands, and plugin-managed hooks. If you choose this path, stop there. Do not also run a full manual install into Claude Code.
> Guided package setup is coming in `ecc-universal` 2.2.0. Use the native
> Claude plugin commands above while npm remains on 2.1.0.
> ECC 2.2 includes guided package setup through `ecc-universal`. The native
> Claude plugin commands above remain the simplest Claude Code install path.
<div align="center">
@@ -168,16 +168,18 @@ Access to 68 agents, 286 skills, and 94 legacy command shims, plus hooks, rules,
## Install ECC
> [!IMPORTANT]
> Guided package setup is coming in `ecc-universal` 2.2.0. The current npm
> release, 2.1.0, does not include the guided setup commands. Use the native
> Claude plugin commands at the top of this README until 2.2.0 is published.
> ECC 2.2 includes guided package setup for Claude Code, Codex, and Kimi Code.
> During registry propagation, run `npm view ecc-universal version` before
> using the package commands. If it still reports 2.1.0, the native Claude
> plugin commands at the top of this README remain available.
### Pick one path only (per harness)
You can use ECC with Claude Code, Codex, and other harnesses at the same time. Choose one install method for each harness:
- **Recommended today for Claude Code:** use the [native plugin commands above](#install-with-claude-code)
- **Coming in release 2.2:** guided package setup for Claude Code, Codex, and Kimi Code; see the preview at the bottom of this install area
- **Recommended default:** run the guided Claude plugin setup below once `npm view ecc-universal version` reports 2.2.0
- **Available throughout npm propagation:** use the [native plugin commands above](#install-with-claude-code)
- **Available in release 2.2:** guided package setup for Claude Code, Codex, and Kimi Code
- **Works:** Claude Code plugin + Codex native plugin
- **Works:** Claude Code plugin + the legacy Codex sync flow
- **Avoid:** Claude Code plugin + full Claude manual install
@@ -191,7 +193,7 @@ If you already layered multiple installs and things look duplicated, skip straig
### Claude Code details
Claude Code owns these built-in commands, including their errors when a marketplace, plugin, or conflicting scope already exists. ECC cannot intercept that parser. If either native command reports an existing install or scope conflict, wait for the 2.2.0 guided setup or resolve the conflicting Claude plugin scope before retrying; do not layer a manual install on top.
Claude Code owns these built-in commands, including their errors when a marketplace, plugin, or conflicting scope already exists. ECC cannot intercept that parser. If either native command reports an existing install or scope conflict, use the 2.2 guided setup or resolve the conflicting Claude plugin scope before retrying; do not layer a manual install on top.
After ECC is installed, `/ecc:configure-ecc` is the namespaced in-Claude reconfiguration skill. It delegates to the same safe setup flow, but it is available only after the plugin is installed and cannot replace Claude Code's built-in `/plugin` command during a first install.
@@ -587,13 +589,12 @@ If you stacked methods, clean up in this order:
4. Reinstall once, using a single path.
</details>
## Coming soon: guided setup in release 2.2
## Guided package setup in release 2.2
> [!WARNING]
> These ECC package-runner commands are not available in the current npm
> release, 2.1.0. Do not run them until `ecc-universal` 2.2.0 is published.
The earlier README description—**Recommended default:** run the guided Claude plugin setup—was published too soon. That recommendation is withdrawn until release 2.2.
> [!IMPORTANT]
> These package-runner commands require `ecc-universal` 2.2.0 or newer.
> Confirm registry propagation with `npm view ecc-universal version`. The
> native Claude plugin install remains available throughout npm rollout.
For Claude Code plugin setup, updates, scope changes, and hook-profile changes:
@@ -601,7 +602,7 @@ For Claude Code plugin setup, updates, scope changes, and hook-profile changes:
npx ecc-universal setup
```
Release 2.2 will support the same guided setup through modern package runners:
ECC 2.2 supports the same guided setup through modern package runners:
| Package runner | Guided setup command |
|---|---|
@@ -610,7 +611,7 @@ Release 2.2 will support the same guided setup through modern package runners:
| Yarn 2+ | `yarn dlx ecc-universal setup` |
| Bun | `bunx ecc-universal setup` |
Yarn Classic 1 does not provide `yarn dlx`; use `npx`, install the package globally, or upgrade Yarn for a temporary one-shot run after 2.2 is published.
Yarn Classic 1 does not provide `yarn dlx`; use `npx`, install the package globally, or upgrade Yarn for a temporary one-shot run.
The wizard inventories the official marketplace and every native Claude install scope before making changes, then installs, updates, or safely moves `ecc@ecc` to the scope you choose. Rerun the same command whenever you want to update ECC, change scope, or change its hook profile. This setup wizard currently configures the Claude Code plugin; use the multi-harness wizard below for Codex or Kimi Code.
@@ -644,7 +645,7 @@ npx ecc-universal install --guided --harness codex --dry-run
npx ecc-universal install --profile core --target kimi --dry-run
```
Additional package-name commands will also become available through the 2.2 alias:
Additional package-name commands are also available through the 2.2 alias:
```bash
npx ecc-universal consult "security reviews" --target claude
@@ -1532,7 +1533,7 @@ See [affaan-m/ECC#2065](https://github.com/affaan-m/ECC/issues/2065).
| Claude Code | Stable primary | Plugin or selective installer | The plugin advertises the installed catalog to the model; use a selective/manual profile when context footprint matters. Optional shell-backed skills are not portable to every OS. |
| Codex | Supported sync; marketplace experimental | Repo config or `sync-ecc-to-codex.sh` | No ECC hook runtime. The marketplace package can omit shared repository content from Codex's cache; use sync for the reliable path. |
| Cursor | Beta project adapter | Selective installer into `.cursor/` | Agent discovery varies by Cursor build, and ECC's installer paths do not yet expose identical hook sets ([#2419](https://github.com/affaan-m/ECC/issues/2419)). |
| OpenCode | Beta built plugin | Build plugin, then selective installer | ECC ships a subset of the catalog and the reference config pins Anthropic models; select models available to your provider ([#2617](https://github.com/affaan-m/ECC/issues/2617)). |
| OpenCode | Beta built plugin | Build plugin, then selective installer | ECC ships a subset of the catalog; connect a provider and select a model in OpenCode ([#2617](https://github.com/affaan-m/ECC/issues/2617)). |
| GitHub Copilot | Instruction-only | Checked-in instructions and prompt files | No ECC hooks, runtime agents, delegation, or native skill discovery. |
| Gemini, Zed, Antigravity, Qwen, Hermes, OpenClaw, Kimi, CodeBuddy, JoyCode | Experimental/minimal adapters | Harness-specific selective target | File placement and instruction portability are tested; full Claude feature parity is not claimed. |
@@ -1718,7 +1719,7 @@ The adapter writes ECC-managed files under `.zed/` and keeps BYOK/OpenRouter cre
<details>
<summary><strong>OpenCode support in depth</strong></summary>
ECC provides a beta OpenCode plugin integration with instructions, a catalog subset, commands, custom tools, and hook events. It does not provide feature parity with Claude Code, and the reference model IDs must exist in the user's configured provider.
ECC provides a beta OpenCode plugin integration with instructions, a catalog subset, commands, custom tools, and hook events. It does not provide feature parity with Claude Code. The reference config inherits the user's OpenCode model selection instead of pinning a provider-specific model.
```bash
# Install OpenCode
@@ -2042,7 +2043,7 @@ Each component is fully independent.
Yes. ECC is cross-platform:
- **Cursor**: Pre-translated configs in `.cursor/`. See [Platform Support](#platform-support).
- **Gemini CLI**: Experimental project-local support via `.gemini/GEMINI.md` and shared installer plumbing.
- **OpenCode**: Beta plugin integration in `.opencode/`; provider model selection and catalog parity remain limited.
- **OpenCode**: Beta plugin integration in `.opencode/`; models follow the user's OpenCode selection, while catalog parity remains limited.
- **Codex**: Supported repo/sync path for macOS app and CLI; ECC's marketplace package remains experimental.
- **GitHub Copilot (VS Code)**: Instruction and prompt layer via `.github/copilot-instructions.md`, `.vscode/settings.json`, and `.github/prompts/`.
- **Antigravity**: Native Antigravity 2.0 setup for workflows, skills, custom agents, and flattened rules in `.agents/`. See [Antigravity Guide](docs/ANTIGRAVITY-GUIDE.md).
+10 -8
View File
@@ -8,16 +8,18 @@ Native Antigravity 2.0 installation requires ECC 2.2.0 or newer. ECC 2.1.0 uses
the legacy `.agent/` adapter and does not provide the native layout described
below.
> [!IMPORTANT]
> **Temporary release status:** npm latest is currently `ecc-universal@2.1.0`.
> ECC 2.2.0 has not been published to npm yet. Until it is published, use a
> current source checkout of `main` for native `.agents` support or wait for the
> release.
<!-- Temporary: remove this release-status paragraph only after `ecc-universal@2.2.0` is published and registry readback succeeds. -->
## Quick start
Verify that 2.2.0 is readable from the registry, then run the pinned package
from the project you want to configure:
```bash
npm view ecc-universal version
npx ecc-universal@2.2.0 install --profile minimal --target antigravity
```
### Source checkout alternative
```bash
# Run every command below from the project you want to configure.
# Keep the ECC source checkout separate and use its absolute path.
+133
View File
@@ -0,0 +1,133 @@
# ECC 2.2 launch and rollback runbook
Affaan is the only release operator for ECC 2.2. Everyone else may prepare,
review, and verify the release candidate, but must not merge the release PR,
create or push `v2.2.0`, change npm dist-tags, or publish the GitHub Release.
## Availability model
The default npm install remains `ecc-universal@2.1.0` until the final promotion
step succeeds. The release workflow publishes 2.2.0 under the `staged` tag,
reads its registry integrity back, compares those bytes with the exact archive
that passed the three-platform lifecycle, and only then moves `latest` to
2.2.0. There is no interval where `latest` points at an unpublished version.
The native Claude marketplace install remains an independent install path
throughout the npm rollout:
```text
/plugin marketplace add https://github.com/affaan-m/ECC
/plugin install ecc@ecc
```
Never unpublish 2.1.0 or 2.2.0. npm dist-tags provide the reversible switch.
## Current fallback baseline
Before merge, confirm all of these:
```bash
npm view ecc-universal dist-tags --json
npm view ecc-universal@2.1.0 dist.integrity
curl -fsSIL https://registry.npmjs.org/ecc-universal/-/ecc-universal-2.1.0.tgz
gh release view v2.1.0 --repo affaan-m/ECC
```
Expected:
- `latest` is `2.1.0`.
- The 2.1.0 tarball returns HTTP 200 and immutable caching headers.
- A clean `npm install ecc-universal@2.1.0` succeeds.
- A disposable managed install and uninstall succeed.
The published 2.1 Cursor adapter can report one non-blocking doctor warning for
an adapted Markdown link. This does not prevent installation or uninstall. ECC
2.2 corrects the packed lifecycle and doctor behavior.
## Preflight before Affaan merges
1. PR #2863 must be mergeable and all required hosted checks must pass.
2. The full local suite, npm audit, IOC scan, and exact packed lifecycle must
pass at the PR head.
3. The packed README must describe 2.2 as available and contain no unpublished
2.2 warning.
4. The Nasiko surface must say experimental CLI lifecycle bridge.
5. `npm view ecc-universal@2.2.0 version` must return E404. Any other registry
error blocks the release.
6. `npm view ecc-universal dist-tags --json` must still show `latest: 2.1.0`.
## The release switch
After Affaan merges PR #2863, wait for CI on the exact `origin/main` commit.
From a clean, current `main` checkout:
```bash
git fetch origin main --tags
git switch main
git pull --ff-only origin main
git status --short
git rev-parse HEAD
git rev-parse origin/main
```
The two commit IDs must match and `git status --short` must print nothing.
Affaan then creates and pushes the signed release tag:
```bash
git tag -s v2.2.0 -m "ECC 2.2.0" HEAD
git tag -v v2.2.0
git push origin refs/tags/v2.2.0
```
That tag push is the only launch switch. The workflow then:
1. Requires the tag commit to equal `origin/main`.
2. Packs and hashes the npm archive once.
3. Runs the exact archive on Linux, macOS, and Windows.
4. Publishes the archive to the npm `staged` tag.
5. Reads back and verifies registry integrity.
6. Atomically promotes the verified version to `latest`.
7. Creates the GitHub Release from the reviewed notes.
## Immediate canary
After the workflow succeeds:
```bash
npm view ecc-universal dist-tags --json
npm view ecc-universal@2.2.0 version dist.integrity
gh release view v2.2.0 --repo affaan-m/ECC
npx --yes ecc-universal@2.2.0 setup --help
npx --yes ecc-universal@latest setup --help
```
Expected:
- Both exact-version and `latest` resolve to 2.2.0.
- Registry integrity matches the workflow output.
- The GitHub Release exists and uses the reviewed notes.
- Both package invocations return the guided setup help.
- The native Claude marketplace remains installable.
Keep watching npm and GitHub install paths during the launch window. Treat an
HTTP failure, integrity mismatch, missing public binary, or failed disposable
install as critical.
## Rollback
If 2.2.0 has an install-critical regression, Affaan or another authorized npm
owner restores the known installable fallback immediately:
```bash
npm dist-tag add ecc-universal@2.1.0 latest
npm view ecc-universal dist-tags --json
ECC_ROLLBACK_ROOT=$(mktemp -d)
npm install --ignore-scripts --prefix "$ECC_ROLLBACK_ROOT" ecc-universal@2.1.0
node "$ECC_ROLLBACK_ROOT/node_modules/ecc-universal/scripts/ecc.js" --help
gh release edit v2.1.0 --repo affaan-m/ECC --latest
```
Then open a release incident, state that 2.2.0 remains available only by exact
version while the incident is investigated, and repair forward with a new patch
version. Do not unpublish either package version and do not reuse the `v2.2.0`
tag.
+42
View File
@@ -0,0 +1,42 @@
# ECC 2.2.0
ECC 2.2.0 makes the universal installer a first-class, cross-harness distribution path. It adds native Antigravity 2.0 support, repairs cumulative install ownership, aligns OpenCode with its canonical configuration directory, and strengthens the exact-artifact release gate.
## Installer and harness reliability
- Antigravity installs natively to `.agents/{rules,workflows,skills,agents}`. Do not manually rename a legacy `.agent` directory. Re-run ECC 2.2.0 so the installer can apply its ownership-aware migration rules.
- Repeated selective installs retain the complete managed ownership ledger. A later module install no longer causes previously installed ECC files to survive uninstall.
- OpenCode home installs use `~/.config/opencode`. Reinstall or repair discovers legacy `~/.opencode` ownership, migrates unchanged ECC-managed files, and preserves modified files for review. Bundled agent definitions inherit the user's selected model provider.
- Legacy Codex sync cleanup requires ownership evidence by default and preserves untracked or modified user files.
- The experimental Nasiko CLI lifecycle bridge recovers locks only when their recorded owner is confirmed dead. Its pinned archive parser rejects malformed boundaries, and incomplete uninstall cleanup returns an error with retained-file guidance. ECC does not connect or operate a Nasiko control plane, enable telemetry, or provide a supported end-to-end Nasiko workflow.
- `skill-comply` is included in both the install graph and npm archive. Python bytecode and pytest caches remain excluded.
## New capabilities
- Guided multi-harness setup and stronger doctor, repair, status, and uninstall flows.
- Native Antigravity 2.0 documentation for Bash and PowerShell.
- Expanded Itô, agent-evaluation, multi-model council, dev-team, living-docs, secure terminal, Pi, and TasteForge workflows, plus the experimental Nasiko CLI lifecycle bridge.
- Improved Plan Canvas, memory vault, continuous learning, skill evolution, hook stability, session handling, and Discord delivery.
## Release assurance
- The release workflow requires the tagged commit to equal `origin/main` exactly.
- npm registry failures stop the release instead of being treated as an unpublished version.
- The exact packed archive is hashed once and exercised on Linux, macOS, and Windows before publication.
- Stable npm releases publish first to a staging dist-tag, verify byte-for-byte registry integrity, and only then promote `latest`. The matching GitHub Release is created after promotion.
- The prior 2.1.0 package remains immutable and installable as the immediate dist-tag rollback target.
## Upgrade
Install or update the published package, then run the same ECC install command you used previously:
```bash
npm install -g ecc-universal@2.2.0
ecc install --target antigravity --profile full
```
Use `ecc doctor --target <target>` after installation. For Antigravity, start a new conversation and verify workspace skills under Settings > Customizations.
## Scope audited
The pre-release audit covered the complete delta from `v2.1.0`: 108 commits, 530 changed files, 40,299 insertions, and 4,679 deletions before the final readiness patch.
@@ -0,0 +1,87 @@
# ECC 2.2 release-readiness TDD evidence
Date: 2026-08-25
## Scope
This pass covers the release blockers found in the delta from `v2.1.0`: cumulative selective-install ownership, native Antigravity packaging, canonical OpenCode installation and conservative legacy migration, provider-neutral OpenCode agents, `skill-comply` distribution, conservative legacy Codex uninstall, release-workflow safety, guided-install filesystem boundaries, npm availability during promotion, and accurate Nasiko release boundaries.
## RED
Commit `6e66dfba` added release regressions before the repairs. All six focused commands exited nonzero on the `origin/main` baseline:
- A second selective install retained only the second module in install-state.
- OpenCode resolved to `~/.opencode` instead of `~/.config/opencode`.
- Managed preflight accepted a plan without an install-state path.
- `skill-comply` was absent from the npm archive.
- Release workflows lacked registry-error discrimination, an exact-main gate, reviewed notes, and npm-first publication ordering.
- The packed lifecycle did not exercise Antigravity or OpenCode.
Commit `528dbea0` added a security regression proving guided preflight accepted an identical copy source through a symbolic link. It failed before the no-follow snapshot repair.
Commit `a504b194` added a release regression after review proved both workflows reused the literal 2.2.0 notes path for later valid versions. Both workflow cases failed before the version-derived notes repair.
Commit `55a2d482` added five OpenCode upgrade regressions. Discovery, uninstall, canonical reinstall, repair migration, and no-follow symlink preservation all failed before the legacy managed-root repair.
Commit `7d9f70c5` changed both workflow contracts to require the repository's established lowercase `release-notes.md` convention. Both cases failed against the uppercase 2.2-only path before the filename repair.
Commit `01779a4a` added final-review regressions for OpenCode configuration overrides, retained content digests, failed non-Claude install checkpoints, and reviewed-only GitHub Release notes. All four areas failed before the corresponding repairs.
Commit `dac154ef` added an end-to-end OpenCode override regression covering discovery, doctor, and uninstall through the same explicit configuration root. It failed before environment-aware lifecycle routing.
The full suite then exposed three guided Kimi collision checks that rejected ECC's own new bridge checkpoint before reaching the protected destination. Commit `15815eca` advanced the expected fingerprint only for ECC-authored state writes while preserving every external state and destination collision check.
Commit `2331afbf` reproduced the hosted-runner failure where ambient OpenCode configuration overrides escaped into callers that supplied an explicit temporary home. Both adapter-root and MCP-inventory regressions failed before invocation contexts were isolated.
Commit `85673326` added legacy OpenCode regressions for custom configuration roots, non-file managed operations, canonical repair routing, and provider-specific auto-update guidance. The migration and guidance cases failed before the final legacy-root repair.
Commit `5aa66021` moved ambient-override checks into isolated child processes and added a regression requiring invocation environments to be immutable snapshots. The snapshot assertion failed before the environment-copy repair.
The final independent audit found a recovery race in legacy OpenCode cleanup: a
clobbering rename could overwrite a user file created after quarantine. A
deterministic injected-filesystem regression now proves recovery fails closed,
keeps the new user file, and retains the old managed file in quarantine.
The same audit found prerelease wording in the immutable npm README, temporary
Antigravity guidance, and wording that overstated the Nasiko feature. Focused
copy regressions now reject those stale statements and require the implemented
surface to be described as an experimental Nasiko CLI lifecycle bridge.
## GREEN
- Focused installer, lifecycle, packaging, release-workflow, manifest, OpenCode, Antigravity, and uninstall tests passed.
- Full repository suite: 3,992 passed, 0 failed.
- `npm audit --audit-level=high`: 0 vulnerabilities.
- Supply-chain IOC scan: 207 files inspected, no findings.
- Both release workflow YAML files parsed successfully.
- Both release workflows derive reviewed notes from the validated tag and fail clearly when that version's notes are absent.
- Release-note selection follows the lowercase filename convention shared by prior release directories.
- Exact packed archive lifecycle passed on macOS with Node 24.9.0 using SHA-256 `019547d032e63ee169abb2f92695dee25d6e60ed64c4085142225d75fb7a76c8`.
- The packed lifecycle covered npm installation, public CLI setup, cumulative Cursor install, drift detection, repair, uninstall, user-file preservation, Antigravity install/doctor/uninstall, and OpenCode install/doctor/uninstall.
- Simulated hosted-runner `OPENCODE_CONFIG_DIR` and `XDG_CONFIG_HOME` overrides passed the adapter, MCP inventory, lifecycle, legacy migration, doctor, repair, list, and uninstall suites while explicit CLI environments continued to honor those overrides.
- The stable workflow publishes 2.2.0 to `staged`, verifies the public registry
SHA-512 against the exact tested archive, and only then promotes `latest`.
- The live npm `latest` tag remained on 2.1.0. A clean exact 2.1.0 package
install and disposable Cursor install/uninstall passed, and its tarball
remained publicly readable with immutable caching.
- A launch and rollback runbook assigns the merge, signed tag, and release to
Affaan and uses the npm dist-tag as the reversible availability switch.
## Focused coverage
All six changed core modules exceeded the 80 percent line target:
| Module | Lines | Functions | Branches |
| --- | ---: | ---: | ---: |
| `scripts/lib/multi-harness-setup.js` | 89.01% | 83.87% | 74.30% |
| `scripts/lib/install/claude-skill-migration.js` | 95.20% | 100% | 88.78% |
| `scripts/lib/install-targets/opencode-home.js` | 86.66% | 100% | 78.94% |
| `scripts/lib/opencode-paths.js` | 100% | 100% | 90.90% |
| `scripts/lib/invocation-environment.js` | 100% | 100% | 87.50% |
| `scripts/lib/install/opencode-legacy-migration.js` | 81.89% | 100% | 70.00% |
Coverage commands used `c8 --check-coverage --lines 80` against the corresponding focused test files.
## Release boundary
No merge, release tag, GitHub Release, or npm publication was performed during this pass.
+1 -1
View File
@@ -205,7 +205,7 @@
{
"id": "capability:nasiko-control-plane",
"family": "capability",
"description": "Explicitly gated Nasiko control-plane installation, status, and agent-operations guidance with pinned artifact verification and opt-in telemetry boundaries.",
"description": "Experimental Nasiko CLI lifecycle bridge guidance for pinned installation, read-only status, qualified uninstall, and opt-in telemetry boundaries.",
"modules": [
"nasiko-control-plane"
]
+2 -1
View File
@@ -326,6 +326,7 @@
"skills/plan-canvas",
"skills/plankton-code-quality",
"skills/production-audit",
"skills/skill-comply",
"skills/skill-scout",
"skills/skill-stocktake",
"skills/strategic-compact",
@@ -638,7 +639,7 @@
{
"id": "nasiko-control-plane",
"kind": "skills",
"description": "Explicitly gated Nasiko control-plane installation, status, and agent-operations guidance with pinned artifact verification and opt-in telemetry boundaries.",
"description": "Experimental Nasiko CLI lifecycle bridge guidance for pinned installation, read-only status, qualified uninstall, and opt-in telemetry boundaries.",
"paths": [
"skills/nasiko-control-plane"
],
+1
View File
@@ -317,6 +317,7 @@
"skills/security-scan/",
"skills/seo/",
"skills/skill-scout/",
"skills/skill-comply/",
"skills/skill-stocktake/",
"skills/social-graph-ranker/",
"skills/springboot-patterns/",
+8 -3
View File
@@ -173,6 +173,13 @@ function runExternalCommand(command, args, options = {}) {
return result;
}
function legacyMigrationWarning(record) {
if (record.legacyLayout === 'opencode') {
return 'Found only a legacy OpenCode ~/.opencode install-state. Run the OpenCode installer once to migrate it to the configured OpenCode directory before auto-updating.';
}
return 'Found only a legacy Antigravity .agent install-state. Run the Antigravity installer once to migrate it to .agents before auto-updating.';
}
function runAutoUpdate(options = {}, dependencies = {}) {
const discover = dependencies.discoverInstalledStates || discoverInstalledStates;
const execute = dependencies.runExternalCommand || runExternalCommand;
@@ -187,9 +194,7 @@ function runAutoUpdate(options = {}, dependencies = {}) {
const records = discoveredRecords.filter(record => record.exists && !record.legacy);
const legacyRecords = discoveredRecords.filter(record => record.exists && record.legacy);
const warnings = records.length === 0 && legacyRecords.length > 0
? [
'Found only a legacy Antigravity .agent install-state. Run the Antigravity installer once to migrate it to .agents before auto-updating.',
]
? [...new Set(legacyRecords.map(legacyMigrationWarning))]
: [];
const results = [];
+1 -3
View File
@@ -18,9 +18,7 @@ const PROFILES_SCHEMA_PATH = path.join(REPO_ROOT, 'schemas/install-profiles.sche
const COMPONENTS_SCHEMA_PATH = path.join(REPO_ROOT, 'schemas/install-components.schema.json');
const CURATED_SKILLS_DIR = path.join(REPO_ROOT, 'skills');
// Empty by default; add only curated skills that are intentionally unshipped.
const INTENTIONALLY_UNSHIPPED_SKILL_IDS = new Set([
'skill-comply', // meta/measurement dev-skill; ships committed .pyc artifacts and a nested .gitignore, revisit after packaging cleanup
]);
const INTENTIONALLY_UNSHIPPED_SKILL_IDS = new Set([]);
const COMPONENT_FAMILY_PREFIXES = {
baseline: 'baseline:',
language: 'lang:',
+1
View File
@@ -96,6 +96,7 @@ function main() {
const report = buildDoctorReport({
repoRoot: require('path').join(__dirname, '..'),
homeDir: process.env.HOME || os.homedir(),
env: process.env,
projectRoot: process.cwd(),
targets: options.targets,
});
+1 -1
View File
@@ -41,7 +41,7 @@ const COMMANDS = {
},
nasiko: {
script: 'nasiko.js',
description: 'Install or inspect the optional pinned Nasiko control-plane CLI',
description: 'Install or inspect the optional pinned Nasiko CLI lifecycle bridge',
},
memory: {
script: 'memory.js',
+2 -1
View File
@@ -39,7 +39,7 @@ Targets:
antigravity - Install rules, workflows, skills, and agents to ./.agents/
codex - Install shared agents/config into ~/.codex/
gemini - Install project-local Gemini config into ./.gemini/
opencode - Install shared commands/hooks/config into ~/.opencode/
opencode - Install into OPENCODE_CONFIG_DIR, XDG_CONFIG_HOME/opencode, or ~/.config/opencode/
codebuddy - Install commands, agents, skills, and flattened rules into ./.codebuddy/
joycode - Install commands, agents, skills, and flattened rules into ./.joycode/
qwen - Install commands, agents, skills, rules, and Qwen config into ~/.qwen/
@@ -164,6 +164,7 @@ async function main() {
const rawPlan = createInstallPlanFromRequest(request, {
projectRoot: process.cwd(),
homeDir: process.env.HOME || os.homedir(),
env: process.env,
claudeRulesDir: process.env.CLAUDE_RULES_DIR || null,
});
+48 -3
View File
@@ -475,6 +475,42 @@ function listLegacyCandidates(codexHome) {
return candidates;
}
function hasMarkerBlock(codexHome) {
const agentsPath = path.join(codexHome, 'AGENTS.md');
try {
const snapshot = readRegularFileNoFollow(agentsPath, 'utf8');
if (snapshot) {
const stripped = stripMarkerBlock(snapshot.content);
return stripped !== snapshot.content;
}
} catch (error) {
// Only ENOENT means "no AGENTS.md" → no marker. Any other error
// (EACCES, EMFILE, EISDIR, symlink-ELOOP, ...) is an indeterminate
// inspection result and must propagate so callers do not read it as
// "clean home". Throwing here is intentional per the repo coding
// guideline: "Always handle errors explicitly at every level and never
// silently swallow errors."
if (error && error.code === 'ENOENT') return false;
throw error;
}
return false;
}
function resolveCodexHome(codexHome) {
return path.resolve(codexHome || process.env.CODEX_HOME || path.join(process.env.HOME || os.homedir(), '.codex'));
}
function legacyCodexSyncStateExists(codexHome) {
const resolvedCodexHome = resolveCodexHome(codexHome);
return readStateIfPresent(getStatePath(resolvedCodexHome)) !== null;
}
function detectLegacyCodexSync(codexHome) {
const resolvedCodexHome = resolveCodexHome(codexHome);
if (readStateIfPresent(getStatePath(resolvedCodexHome))) return true;
return hasMarkerBlock(resolvedCodexHome);
}
function uninstallLegacyCodexSync(options = {}) {
const codexHome = path.resolve(options.codexHome || process.env.CODEX_HOME || path.join(process.env.HOME || os.homedir(), '.codex'));
const statePath = getStatePath(codexHome);
@@ -494,17 +530,24 @@ function uninstallLegacyCodexSync(options = {}) {
const stripped = stripMarkerBlock(content);
if (stripped !== content) {
plannedRemovals.push(`${agentsPath}#ecc-marker-block`);
if (!dryRun) replaceOpenedRegularFile(openedAgents, stripped, openedAgents.stat.mode & 0o777);
if (!dryRun) {
replaceOpenedRegularFile(openedAgents, stripped, openedAgents.stat.mode & 0o777);
removedPaths.push(agentsPath);
}
}
}
} catch (_error) {
retainedPaths.push(agentsPath);
if (_error.code !== 'ENOENT') retainedPaths.push(agentsPath);
} finally {
if (openedAgents) fs.closeSync(openedAgents.descriptor);
}
retainedPaths.push(...listLegacyCandidates(codexHome));
const hasWork = plannedRemovals.length > 0 || removedPaths.length > 0;
const status = dryRun
? (hasWork || retainedPaths.length > 0 ? 'planned' : 'not-found')
: (retainedPaths.length > 0 ? 'partial' : (hasWork ? 'uninstalled' : 'not-found'));
return {
status: dryRun ? 'planned' : retainedPaths.length > 0 ? 'partial' : plannedRemovals.length > 0 ? 'uninstalled' : 'not-found',
status,
statePath: null,
plannedRemovals,
removedPaths,
@@ -594,8 +637,10 @@ module.exports = {
END_MARKER,
SCHEMA,
beginLegacySyncState,
detectLegacyCodexSync,
finalizeLegacySyncState,
getStatePath,
legacyCodexSyncStateExists,
recordLegacySyncPath,
rollbackLegacyCodexSync,
stripMarkerBlock,
+4 -3
View File
@@ -135,8 +135,9 @@ const HARNESS_CAPABILITIES = deepFreeze([
installMode: 'managed-home',
guidedReady: false,
availability: 'advanced',
destination: '~/.opencode',
scopes: [scope('home', 'opencode', '~/.opencode')],
destination: '~/.config/opencode',
destinationResolution: 'OPENCODE_CONFIG_DIR, then XDG_CONFIG_HOME/opencode, then ~/.config/opencode',
scopes: [scope('home', 'opencode', '~/.config/opencode')],
hooks: hooks(
'adapter-opt-in',
false,
@@ -249,7 +250,7 @@ for (const harness of HARNESS_CAPABILITIES) {
function expectedRootForAdapter(adapter) {
const homeDir = path.resolve('/__ecc_catalog_home__');
const projectRoot = path.resolve('/__ecc_catalog_project__');
const absoluteRoot = adapter.resolveRoot({ homeDir, projectRoot });
const absoluteRoot = adapter.resolveRoot({ homeDir, projectRoot, env: {} });
const baseRoot = adapter.kind === 'home' ? homeDir : projectRoot;
const prefix = adapter.kind === 'home' ? '~/' : './';
return `${prefix}${path.relative(baseRoot, absoluteRoot).replace(/\\/g, '/')}`;
+10 -1
View File
@@ -7,6 +7,7 @@ const { toCursorAgentRelativePath } = require('./cursor-agent-names');
const { LEGACY_INSTALL_TARGETS, parseInstallArgs } = require('./install/request');
const { SUPPORTED_INSTALL_TARGETS, listLegacyCompatibilityLanguages, resolveLegacyCompatibilitySelection, resolveInstallPlan } = require('./install-manifests');
const { getInstallTargetAdapter } = require('./install-targets/registry');
const { resolveInvocationEnvironment } = require('./invocation-environment');
const LANGUAGE_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
const CLAUDE_ECC_NAMESPACE = 'ecc';
@@ -80,7 +81,12 @@ function validateLegacyTarget(target) {
throw new Error(`Unknown install target: ${target}. Expected one of ${SUPPORTED_INSTALL_TARGETS.join(', ')}`);
}
const IGNORED_DIRECTORY_NAMES = new Set(['node_modules', '.git', '__pycache__']);
const IGNORED_DIRECTORY_NAMES = new Set([
'node_modules',
'.git',
'__pycache__',
'.pytest_cache',
]);
const IGNORED_FILE_EXTENSIONS = new Set(['.pyc', '.pyo', '.pyd']);
function listFilesRecursive(dirPath) {
@@ -640,6 +646,7 @@ function createLegacyCompatInstallPlan(options = {}) {
sourceRoot,
projectRoot,
homeDir: options.homeDir,
env: resolveInvocationEnvironment(options),
target,
profileId: null,
moduleIds: selection.moduleIds,
@@ -769,6 +776,7 @@ function createManifestInstallPlan(options = {}) {
repoRoot: sourceRoot,
projectRoot,
homeDir: options.homeDir,
env: resolveInvocationEnvironment(options),
profileId: options.profileId || null,
moduleIds: options.moduleIds || [],
includeComponentIds: options.includeComponentIds || [],
@@ -822,6 +830,7 @@ function createManifestInstallPlan(options = {}) {
target: adapter.target,
kind: adapter.kind
},
homeDir: plan.homeDir,
targetRoot: plan.targetRoot,
installRoot: plan.targetRoot,
installStatePath: plan.installStatePath,
+144 -16
View File
@@ -15,9 +15,14 @@ const {
getLegacyAntigravityLocation,
inspectLegacyAntigravityState,
} = require('./install/antigravity-legacy-migration');
const {
getLegacyOpencodeLocation,
inspectLegacyOpencodeState,
} = require('./install/opencode-legacy-migration');
const { adaptAntigravityAgent } = require('./install/antigravity-agent');
const { buildInstallIndex, rewriteRelativeLinks } = require('./install/link-rewrite');
const { getInstallTargetAdapter, listInstallTargetAdapters } = require('./install-targets/registry');
const { resolveInvocationEnvironment } = require('./invocation-environment');
const OPENCODE_BUILD_ARTIFACT = path.join('.opencode', 'dist');
const OPENCODE_BUILD_SCRIPT = path.join('scripts', 'build-opencode.js');
const OPENCODE_PLUGIN_NOT_BUILT_CODE = 'opencode-plugin-not-built';
@@ -68,6 +73,7 @@ function getOpencodeBuildValidationIssues(context) {
return getInstallTargetAdapter('opencode').validate({
homeDir: context.homeDir,
repoRoot: context.repoRoot,
env: context.env,
});
}
@@ -1187,7 +1193,8 @@ function buildDiscoveryRecord(adapter, context, location = null, knownState = nu
const installTargetInput = {
homeDir: context.homeDir,
projectRoot: context.projectRoot,
repoRoot: context.projectRoot
repoRoot: context.projectRoot,
env: context.env,
};
const targetRoot = location
? location.targetRoot
@@ -1209,7 +1216,8 @@ function buildDiscoveryRecord(adapter, context, location = null, knownState = nu
exists: false,
state: null,
error: null,
legacy: Boolean(location)
legacy: Boolean(location),
legacyLayout: location?.legacyLayout || null
};
}
@@ -1225,7 +1233,8 @@ function buildDiscoveryRecord(adapter, context, location = null, knownState = nu
exists: true,
state: knownState,
error: null,
legacy: Boolean(location)
legacy: Boolean(location),
legacyLayout: location?.legacyLayout || null
};
}
@@ -1242,7 +1251,8 @@ function buildDiscoveryRecord(adapter, context, location = null, knownState = nu
exists: true,
state,
error: null,
legacy: Boolean(location)
legacy: Boolean(location),
legacyLayout: location?.legacyLayout || null
};
} catch (error) {
return {
@@ -1256,7 +1266,8 @@ function buildDiscoveryRecord(adapter, context, location = null, knownState = nu
exists: true,
state: null,
error: error.message,
legacy: Boolean(location)
legacy: Boolean(location),
legacyLayout: location?.legacyLayout || null
};
}
}
@@ -1264,18 +1275,54 @@ function buildDiscoveryRecord(adapter, context, location = null, knownState = nu
function discoverInstalledStates(options = {}) {
const context = {
homeDir: options.homeDir || process.env.HOME || os.homedir(),
projectRoot: options.projectRoot || process.cwd()
projectRoot: options.projectRoot || process.cwd(),
env: resolveInvocationEnvironment(options),
};
const targets = normalizeTargets(options.targets);
return targets.flatMap(target => {
const adapter = getInstallTargetAdapter(target);
const canonicalRecord = buildDiscoveryRecord(adapter, context);
if (adapter.target === 'opencode') {
const legacyLocation = getLegacyOpencodeLocation(context.homeDir);
const legacyInspection = inspectLegacyOpencodeState(legacyLocation);
if (
path.resolve(legacyLocation.installStatePath) === path.resolve(canonicalRecord.installStatePath)
|| legacyInspection.status === 'absent'
|| legacyInspection.status === 'invalid'
) {
return [canonicalRecord];
}
if (legacyInspection.status === 'unreadable') {
return [canonicalRecord, {
adapter: {
id: adapter.id,
target: adapter.target,
kind: adapter.kind,
},
targetRoot: legacyLocation.targetRoot,
installStatePath: legacyLocation.installStatePath,
exists: true,
state: null,
error: legacyInspection.error,
legacy: true,
legacyLayout: 'opencode',
}];
}
return [
canonicalRecord,
buildDiscoveryRecord(adapter, context, legacyLocation, legacyInspection.state),
];
}
if (adapter.target !== 'antigravity') {
return [canonicalRecord];
}
const legacyLocation = getLegacyAntigravityLocation(context.projectRoot);
const legacyLocation = {
...getLegacyAntigravityLocation(context.projectRoot),
legacyLayout: 'antigravity',
};
const legacyInspection = inspectLegacyAntigravityState(legacyLocation);
if (
path.resolve(legacyLocation.installStatePath) === path.resolve(canonicalRecord.installStatePath)
@@ -1296,8 +1343,9 @@ function discoverInstalledStates(options = {}) {
installStatePath: legacyLocation.installStatePath,
exists: true,
state: null,
error: legacyInspection.error,
legacy: true,
error: legacyInspection.error,
legacy: true,
legacyLayout: 'antigravity',
}];
}
@@ -1332,7 +1380,7 @@ function determineStatus(issues) {
function analyzeRecord(record, context) {
const issues = [];
if (record.legacy) {
if (record.legacyLayout === 'antigravity') {
issues.push(buildIssue(
'warning',
'legacy-antigravity-layout',
@@ -1340,6 +1388,14 @@ function analyzeRecord(record, context) {
));
}
if (record.legacyLayout === 'opencode') {
issues.push(buildIssue(
'warning',
'legacy-opencode-layout',
'Legacy OpenCode install-state remains under ~/.opencode. Rerun the OpenCode install or repair command to migrate unchanged ECC-managed files to ~/.config/opencode; modified files are preserved for review.'
));
}
if (record.error) {
issues.push(buildIssue('error', 'invalid-install-state', record.error));
return {
@@ -1454,6 +1510,7 @@ function analyzeRecord(record, context) {
repoRoot: context.repoRoot,
projectRoot: context.projectRoot,
homeDir: context.homeDir,
env: context.env,
target: record.adapter.target,
profileId: state.request.profile || null,
moduleIds: state.request.modules || [],
@@ -1489,12 +1546,14 @@ function buildDoctorReport(options = {}) {
const records = discoverInstalledStates({
homeDir: options.homeDir,
projectRoot: options.projectRoot,
targets: options.targets
targets: options.targets,
env: resolveInvocationEnvironment(options),
}).filter(record => record.exists);
const context = {
repoRoot,
homeDir: options.homeDir || process.env.HOME || os.homedir(),
projectRoot: options.projectRoot || process.cwd(),
env: resolveInvocationEnvironment(options),
manifestVersion: manifests.modulesVersion,
packageVersion: readPackageVersion(repoRoot)
};
@@ -1534,7 +1593,10 @@ function createRepairPlanFromRecord(record, context, options = {}) {
throw new Error('No install-state available for repair');
}
if (state.request.legacyMode || shouldRepairFromRecordedOperations(state)) {
if (
record.legacyLayout !== 'opencode'
&& (state.request.legacyMode || shouldRepairFromRecordedOperations(state))
) {
const operations = hydrateRecordedOperations(context.repoRoot, getManagedOperations(state));
const statePreview = buildRecordedStatePreview(state, context, operations);
@@ -1561,6 +1623,7 @@ function createRepairPlanFromRecord(record, context, options = {}) {
excludeComponentIds: state.request.excludeComponents || [],
projectRoot: context.projectRoot,
homeDir: context.homeDir,
env: context.env,
exemptValidationCodes: options.exemptValidationCodes || [],
});
@@ -1659,6 +1722,7 @@ function repairInstalledStates(options = {}) {
repoRoot,
homeDir: options.homeDir || process.env.HOME || os.homedir(),
projectRoot: options.projectRoot || process.cwd(),
env: resolveInvocationEnvironment(options),
manifestVersion: manifests.modulesVersion,
packageVersion: readPackageVersion(repoRoot)
};
@@ -1668,8 +1732,12 @@ function repairInstalledStates(options = {}) {
const records = discoverInstalledStates({
homeDir: context.homeDir,
projectRoot: context.projectRoot,
targets: options.targets
}).filter(record => record.exists && !record.legacy);
targets: options.targets,
env: context.env,
}).filter(record => (
record.exists
&& (!record.legacy || record.legacyLayout === 'opencode')
));
const results = records.map(record => {
if (record.error) {
@@ -1688,6 +1756,65 @@ function repairInstalledStates(options = {}) {
&& hasOpencodeBuildError(getOpencodeBuildValidationIssues(context));
const opencodeBuildRepairPath = path.join(context.repoRoot, OPENCODE_BUILD_ARTIFACT);
if (record.legacyLayout === 'opencode') {
if (needsOpencodeBuild && !options.dryRun) {
try {
buildOpencodeRunner(context.repoRoot);
} catch (error) {
return {
adapter: record.adapter,
status: 'error',
installStatePath: record.installStatePath,
repairedPaths: [],
plannedRepairs: [],
error: formatBuildErrorMessage(error),
};
}
}
const canonicalPlan = createRepairPlanFromRecord(record, context, {
exemptValidationCodes: options.dryRun && needsOpencodeBuild
? [OPENCODE_PLUGIN_NOT_BUILT_CODE]
: [],
});
const plannedRepairs = [...new Set([
...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []),
...canonicalPlan.operations.map(operation => operation.destinationPath),
...getManagedOperations(record.state).map(operation => operation.destinationPath),
record.installStatePath,
])];
if (options.dryRun) {
return {
adapter: record.adapter,
status: 'planned',
installStatePath: canonicalPlan.installStatePath,
repairedPaths: [],
plannedRepairs,
stateRefreshed: false,
warnings: canonicalPlan.warnings,
error: null,
};
}
// Load lazily to avoid a module cycle during install-lifecycle startup.
const { applyInstallPlan } = require('./install/apply');
const appliedPlan = applyInstallPlan(canonicalPlan);
return {
adapter: record.adapter,
status: 'repaired',
installStatePath: canonicalPlan.installStatePath,
repairedPaths: [
...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []),
...canonicalPlan.operations.map(operation => operation.destinationPath),
],
plannedRepairs: [],
stateRefreshed: true,
warnings: appliedPlan.warnings,
error: null,
};
}
if (needsOpencodeBuild && options.dryRun) {
const rawPlan = createRepairPlanFromRecord(record, context, {
exemptValidationCodes: [OPENCODE_PLUGIN_NOT_BUILT_CODE],
@@ -1921,7 +2048,8 @@ function uninstallInstalledStates(options = {}) {
const records = discoverInstalledStates({
homeDir: options.homeDir,
projectRoot: options.projectRoot,
targets: options.targets
targets: options.targets,
env: resolveInvocationEnvironment(options),
}).filter(record => record.exists);
const results = records.map(record => {
@@ -1938,7 +2066,7 @@ function uninstallInstalledStates(options = {}) {
const state = record.state;
const managedOperations = getManagedOperations(state);
if (record.legacy && managedOperations.length > 0) {
if (record.legacyLayout === 'antigravity' && managedOperations.length > 0) {
return {
adapter: record.adapter,
status: 'partial',
+4
View File
@@ -2,6 +2,7 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
const { getInstallTargetAdapter, planInstallTargetScaffold } = require('./install-targets/registry');
const { resolveInvocationEnvironment } = require('./invocation-environment');
const DEFAULT_REPO_ROOT = path.join(__dirname, '../..');
const SUPPORTED_INSTALL_TARGETS = ['claude', 'claude-project', 'cursor', 'antigravity', 'codex', 'gemini', 'opencode', 'codebuddy', 'joycode', 'qwen', 'zed', 'hermes', 'openclaw', 'kimi'];
@@ -595,6 +596,7 @@ function resolveInstallPlan(options = {}) {
repoRoot: manifests.repoRoot,
projectRoot: validatedProjectRoot || manifests.repoRoot,
homeDir: validatedHomeDir || os.homedir(),
env: resolveInvocationEnvironment(options),
}
: null;
const targetAdapter = target ? getInstallTargetAdapter(target) : null;
@@ -693,6 +695,7 @@ function resolveInstallPlan(options = {}) {
repoRoot: targetPlanningInput.repoRoot,
projectRoot: targetPlanningInput.projectRoot,
homeDir: targetPlanningInput.homeDir,
env: targetPlanningInput.env,
modules: selectedModules,
exemptValidationCodes: options.exemptValidationCodes || [],
})
@@ -719,6 +722,7 @@ function resolveInstallPlan(options = {}) {
skippedModules,
excludedModules,
targetAdapterId: scaffoldPlan ? scaffoldPlan.adapter.id : null,
homeDir: targetPlanningInput ? targetPlanningInput.homeDir : null,
targetRoot: scaffoldPlan ? scaffoldPlan.targetRoot : null,
installStatePath: scaffoldPlan ? scaffoldPlan.installStatePath : null,
operations: scaffoldPlan ? scaffoldPlan.operations : [],
+1
View File
@@ -51,6 +51,7 @@ async function reconcileCanonicalInstallStates(options = {}) {
homeDir: options.homeDir,
projectRoot: options.projectRoot,
targets: options.targets,
env: options.env,
discoverInstalledStates: options.discoverInstalledStates,
}));
}
+3
View File
@@ -264,6 +264,9 @@ function createInstallTargetAdapter(config) {
},
resolveRoot(input = {}) {
const baseRoot = resolveBaseRoot(config.kind, input);
if (typeof config.resolveRoot === 'function') {
return config.resolveRoot(input, baseRoot);
}
return path.join(baseRoot, ...config.rootSegments);
},
getInstallStatePath(input = {}) {
+3 -1
View File
@@ -6,6 +6,7 @@ const {
buildValidationIssue,
createInstallTargetAdapter,
} = require('./helpers');
const { resolveOpencodeConfigRoot } = require('../opencode-paths');
const COMPILED_PLUGIN_DIST_DIR = path.join('.opencode', 'dist');
const REQUIRED_COMPILED_ARTEFACTS = Object.freeze([
@@ -83,7 +84,8 @@ module.exports = createInstallTargetAdapter({
id: 'opencode-home',
target: 'opencode',
kind: 'home',
rootSegments: ['.opencode'],
rootSegments: ['.config', 'opencode'],
resolveRoot: resolveOpencodeConfigRoot,
installStatePathSegments: ['ecc-install-state.json'],
nativeRootRelativePath: '.opencode',
validate: defaultValidateOpencodeHome,
+2
View File
@@ -12,6 +12,7 @@ const openclawHome = require('./openclaw-home');
const opencodeHome = require('./opencode-home');
const qwenHome = require('./qwen-home');
const zedProject = require('./zed-project');
const { resolveInvocationEnvironment } = require('../invocation-environment');
const ADAPTERS = Object.freeze([
claudeHome,
@@ -52,6 +53,7 @@ function planInstallTargetScaffold(options = {}) {
repoRoot: options.repoRoot,
projectRoot: options.projectRoot || options.repoRoot,
homeDir: options.homeDir,
env: resolveInvocationEnvironment(options),
};
const validationIssues = adapter.validate(planningInput);
const blockingIssues = validationIssues.filter(issue => (
+34
View File
@@ -17,6 +17,7 @@ const {
removeLegacyClaudeSkillFiles,
} = require('./claude-skill-migration');
const { cleanupLegacyAntigravityInstall } = require('./antigravity-legacy-migration');
const { cleanupLegacyOpencodeInstall } = require('./opencode-legacy-migration');
const { buildInstallIndex, rewriteRelativeLinks } = require('./link-rewrite');
const { adaptAntigravityAgent } = require('./antigravity-agent');
@@ -119,12 +120,25 @@ function readInstalledFileNoFollow(plan, operation) {
}
function stateWithContentDigests(state, plan) {
const currentDestinations = new Set((plan.operations || [])
.filter(operation => operation.destinationPath)
.map(operation => {
const resolved = path.resolve(operation.destinationPath);
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
}));
return {
...state,
operations: (state.operations || []).map(operation => {
if (!operation.destinationPath) {
return { ...operation };
}
const resolved = path.resolve(operation.destinationPath);
const destinationKey = process.platform === 'win32'
? resolved.toLowerCase()
: resolved;
if (!currentDestinations.has(destinationKey)) {
return { ...operation };
}
const installedContent = readInstalledFileNoFollow(plan, operation);
if (installedContent === null) {
return { ...operation };
@@ -337,8 +351,12 @@ function previewInstallPlan(plan) {
function applyInstallPlan(plan, dependencies = {}) {
const persistInstallState = dependencies.writeInstallState || writeInstallState;
const beforeInstallStateRead = dependencies.beforeInstallStateRead;
const beforeOperationWrite = dependencies.beforeOperationWrite;
const beforeInstallStateWrite = dependencies.beforeInstallStateWrite;
if (typeof beforeInstallStateRead === 'function') {
beforeInstallStateRead({ plan });
}
const migration = prepareClaudeSkillMigration(plan);
const appliedPlan = {
...plan,
@@ -489,6 +507,21 @@ function applyInstallPlan(plan, dependencies = {}) {
];
}
let opencodeMigrationWarnings = [];
try {
const opencodeMigration = cleanupLegacyOpencodeInstall(appliedPlan);
if (opencodeMigration.detected && !opencodeMigration.complete) {
opencodeMigrationWarnings = [
'Legacy OpenCode migration is incomplete. ECC preserved modified or unverifiable managed content under ~/.opencode; review it and rerun the OpenCode install.',
...(Array.isArray(opencodeMigration.warnings) ? opencodeMigration.warnings : []),
];
}
} catch (error) {
opencodeMigrationWarnings = [
`Legacy OpenCode cleanup did not finish: ${error.message}. Content under ~/.opencode was preserved; rerun the OpenCode install or review it manually.`,
];
}
return {
...plan,
statePreview: finalState,
@@ -499,6 +532,7 @@ function applyInstallPlan(plan, dependencies = {}) {
...(Array.isArray(plan.warnings) ? plan.warnings : []),
...migration.warnings,
...antigravityMigrationWarnings,
...opencodeMigrationWarnings,
],
applied: true,
};
+25 -23
View File
@@ -133,19 +133,13 @@ function isManagedOperation(operation) {
}
function uniqueOperations(operations) {
const seen = new Set();
return operations.filter(operation => {
const key = [
operation.kind,
normalizeSourceRelativePath(operation.sourceRelativePath) || operation.sourceRelativePath,
comparablePath(operation.destinationPath),
].join('\0');
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
const byDestination = new Map();
for (const operation of operations) {
// A target path has one current owner. Later operations come from the
// newest plan and replace stale metadata for the same destination.
byDestination.set(comparablePath(operation.destinationPath), operation);
}
return [...byDestination.values()];
}
function buildState(statePreview, operations) {
@@ -236,16 +230,20 @@ function createFileConflictWarning(destinationPath, retainsLegacy) {
return `Skipped user-owned Claude skill file ${destinationPath}: the existing file is not recorded in ECC install-state.${legacySuffix}`;
}
function createDisabledMigration(plan) {
function createDisabledMigration(plan, previousState) {
const finalState = buildState(plan.statePreview, [
...((previousState && previousState.operations) || []),
...plan.statePreview.operations,
]);
return {
enabled: false,
appliedOperations: [...plan.operations],
skippedOperations: [],
warnings: [],
bridgeState: plan.statePreview,
finalState: plan.statePreview,
bridgeState: finalState,
finalState,
legacyOperationsToRemove: [],
requiresBridgeState: false,
requiresBridgeState: plan.operations.length > 0,
};
}
@@ -331,11 +329,16 @@ function buildMigrationStates(plan, previousState, previous, classification) {
const legacyOperationsToRemove = legacyOperations.filter(operation => (
!retainedLegacyOperations.has(operation)
));
const removedLegacyDestinations = new Set(
legacyOperationsToRemove.map(operation => comparablePath(operation.destinationPath))
);
const finalOperations = [
...((previousState && previousState.operations) || []).filter(operation => (
!removedLegacyDestinations.has(comparablePath(operation.destinationPath))
)),
...plan.statePreview.operations.filter(operation => (
!skippedDestinations.has(comparablePath(operation.destinationPath))
)),
...retainedLegacyOperations,
];
const bridgeOperations = [
...((previousState && previousState.operations) || []),
@@ -352,14 +355,13 @@ function buildMigrationStates(plan, previousState, previous, classification) {
}
function prepareClaudeSkillMigration(plan) {
const target = plan && plan.adapter && plan.adapter.target;
if (!CLAUDE_TARGETS.has(target)) {
return createDisabledMigration(plan);
}
const previousState = pathExists(plan.installStatePath)
? readInstallState(plan.installStatePath)
: null;
const target = plan && plan.adapter && plan.adapter.target;
if (!CLAUDE_TARGETS.has(target)) {
return createDisabledMigration(plan, previousState);
}
const currentGroups = groupCurrentSkillOperations(plan);
const previous = classifyPreviousOperations(plan, previousState);
const classification = classifySkillConflicts(currentGroups, previous);
@@ -0,0 +1,398 @@
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { readInstallState } = require('../install-state');
const { assertWithinTrustedRoot } = require('../path-safety');
const OPENCODE_TARGET = 'opencode';
const INSTALL_STATE_NAME = 'ecc-install-state.json';
function samePath(leftPath, rightPath) {
const left = path.resolve(leftPath);
const right = path.resolve(rightPath);
return process.platform === 'win32'
? left.toLowerCase() === right.toLowerCase()
: left === right;
}
function pathExists(filePath) {
try {
fs.lstatSync(filePath);
return true;
} catch (error) {
if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) {
return false;
}
throw error;
}
}
function getLegacyOpencodeLocation(homeDir) {
const targetRoot = path.join(path.resolve(homeDir), '.opencode');
return {
targetRoot,
installStatePath: path.join(targetRoot, INSTALL_STATE_NAME),
legacyLayout: 'opencode',
};
}
function getLegacyLocationForPlan(plan) {
if (
!plan
|| plan.adapter?.target !== OPENCODE_TARGET
|| typeof plan.targetRoot !== 'string'
) {
return null;
}
if (typeof plan.homeDir === 'string' && plan.homeDir.trim() !== '') {
return getLegacyOpencodeLocation(plan.homeDir);
}
const canonicalRoot = path.resolve(plan.targetRoot);
if (
path.basename(canonicalRoot) !== 'opencode'
|| path.basename(path.dirname(canonicalRoot)) !== '.config'
) {
return null;
}
return getLegacyOpencodeLocation(path.dirname(path.dirname(canonicalRoot)));
}
function inspectLegacyOpencodeState(location) {
if (!location) {
return { status: 'absent', state: null, error: null };
}
try {
if (!pathExists(location.installStatePath)) {
return { status: 'absent', state: null, error: null };
}
const rootStat = fs.lstatSync(location.targetRoot);
const stateStat = fs.lstatSync(location.installStatePath);
if (
!rootStat.isDirectory()
|| rootStat.isSymbolicLink()
|| !stateStat.isFile()
|| stateStat.isSymbolicLink()
) {
return { status: 'invalid', state: null, error: null };
}
const state = readInstallState(location.installStatePath);
const isOpencode = state.target.target === OPENCODE_TARGET
|| state.target.id === 'opencode-home';
if (
!isOpencode
|| !samePath(state.target.root, location.targetRoot)
|| !samePath(state.target.installStatePath, location.installStatePath)
) {
return { status: 'invalid', state: null, error: null };
}
return { status: 'valid', state, error: null };
} catch (error) {
return {
status: 'unreadable',
state: null,
error: `Unable to inspect legacy OpenCode install-state at ${location.installStatePath}: ${error.message}`,
};
}
}
function hashFileNoFollow(filePath) {
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
const descriptor = fs.openSync(filePath, flags);
try {
const before = fs.fstatSync(descriptor, { bigint: true });
if (!before.isFile()) {
throw new Error(`Refusing to read a non-file at ${filePath}`);
}
const content = fs.readFileSync(descriptor);
const after = fs.fstatSync(descriptor, { bigint: true });
const finalPathStat = fs.lstatSync(filePath, { bigint: true });
const unchanged = before.dev === after.dev
&& before.ino === after.ino
&& before.size === after.size
&& before.mtimeMs === after.mtimeMs
&& before.ctimeMs === after.ctimeMs
&& after.dev === finalPathStat.dev
&& after.ino === finalPathStat.ino
&& after.size === finalPathStat.size
&& after.mtimeMs === finalPathStat.mtimeMs
&& after.ctimeMs === finalPathStat.ctimeMs;
if (finalPathStat.isSymbolicLink() || !finalPathStat.isFile() || !unchanged) {
throw new Error(`Refusing to read a file that changed during validation: ${filePath}`);
}
return {
digest: crypto.createHash('sha256').update(content).digest('hex'),
stat: after,
};
} finally {
fs.closeSync(descriptor);
}
}
function removeEmptyParents(startPath, legacyRoot) {
let currentPath = path.dirname(startPath);
while (!samePath(currentPath, legacyRoot)) {
const safePath = assertWithinTrustedRoot(
currentPath,
legacyRoot,
'clean legacy OpenCode install'
);
if (!pathExists(safePath)) {
currentPath = path.dirname(safePath);
continue;
}
const stat = fs.lstatSync(safePath);
if (!stat.isDirectory() || stat.isSymbolicLink() || fs.readdirSync(safePath).length > 0) {
return;
}
fs.rmdirSync(safePath);
currentPath = path.dirname(safePath);
}
}
function verifyManagedLegacyFile(operation, location, sourceRoot) {
if (operation?.ownership !== 'managed' || operation?.kind !== 'copy-file') {
return { skipped: true };
}
if (
typeof operation.destinationPath !== 'string'
|| typeof operation.sourceRelativePath !== 'string'
|| !/^[a-f0-9]{64}$/i.test(operation.contentSha256 || '')
) {
return { retainedPath: operation?.destinationPath || location.targetRoot };
}
let destinationPath;
let sourcePath;
try {
destinationPath = assertWithinTrustedRoot(
operation.destinationPath,
location.targetRoot,
'migrate legacy OpenCode install'
);
sourcePath = assertWithinTrustedRoot(
path.join(sourceRoot, operation.sourceRelativePath),
sourceRoot,
'verify legacy OpenCode source'
);
} catch (_error) {
return { retainedPath: operation.destinationPath };
}
let destination;
try {
destination = hashFileNoFollow(destinationPath);
} catch (error) {
if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) {
return { missing: true };
}
return { retainedPath: destinationPath };
}
if (destination.digest !== operation.contentSha256.toLowerCase()) {
return { retainedPath: destinationPath };
}
let source;
try {
source = hashFileNoFollow(sourcePath);
} catch (_error) {
return { retainedPath: destinationPath };
}
if (source.digest !== destination.digest) {
return { retainedPath: destinationPath };
}
return { destinationPath, stat: destination.stat };
}
function pathExistsWith(fileSystem, filePath) {
try {
fileSystem.lstatSync(filePath);
return true;
} catch (error) {
if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) {
return false;
}
throw error;
}
}
function restoreQuarantinedFileNoClobber(quarantinePath, safePath, fileSystem) {
try {
fileSystem.linkSync(quarantinePath, safePath);
} catch (error) {
error.retainedPath = quarantinePath;
throw error;
}
try {
fileSystem.rmSync(quarantinePath);
} catch (error) {
error.retainedPath = quarantinePath;
throw error;
}
}
function removeVerifiedLegacyFile(entry, location, fileSystem = fs) {
const safePath = assertWithinTrustedRoot(
entry.destinationPath,
location.targetRoot,
'remove verified legacy OpenCode file'
);
const quarantineDir = fileSystem.mkdtempSync(path.join(
path.dirname(location.targetRoot),
'.ecc-opencode-remove-'
));
const quarantinePath = path.join(quarantineDir, path.basename(safePath));
try {
fileSystem.renameSync(safePath, quarantinePath);
const quarantinedStat = fileSystem.lstatSync(quarantinePath, { bigint: true });
const identityMatches = !quarantinedStat.isSymbolicLink()
&& quarantinedStat.isFile()
&& quarantinedStat.dev === entry.stat.dev
&& quarantinedStat.ino === entry.stat.ino;
if (!identityMatches) {
const identityError = new Error(
`Legacy OpenCode file changed during quarantine: ${safePath}`
);
identityError.code = 'ESTALE';
throw identityError;
}
fileSystem.rmSync(quarantinePath);
fileSystem.rmdirSync(quarantineDir);
return true;
} catch (error) {
let restoreError = null;
try {
if (pathExistsWith(fileSystem, quarantinePath)) {
restoreQuarantinedFileNoClobber(quarantinePath, safePath, fileSystem);
}
if (
pathExistsWith(fileSystem, quarantineDir)
&& fileSystem.readdirSync(quarantineDir).length === 0
) {
fileSystem.rmdirSync(quarantineDir);
}
} catch (recoveryError) {
restoreError = recoveryError;
}
if (restoreError) {
restoreError.cause = error;
throw restoreError;
}
throw error;
}
}
function emptyCleanupResult() {
return {
detected: false,
complete: false,
removedPaths: [],
retainedPaths: [],
warnings: [],
};
}
function hasTrustedCanonicalState(plan) {
if (typeof plan.sourceRoot !== 'string' || !pathExists(plan.installStatePath)) {
return false;
}
try {
const canonicalState = readInstallState(plan.installStatePath);
return !(
(canonicalState.target.target !== OPENCODE_TARGET
&& canonicalState.target.id !== 'opencode-home')
|| !samePath(canonicalState.target.root, plan.targetRoot)
|| !samePath(canonicalState.target.installStatePath, plan.installStatePath)
);
} catch (_error) {
return false;
}
}
function classifyLegacyOperations(inspection, location, sourceRoot) {
const removable = [];
const retainedPaths = [];
for (const operation of inspection.state.operations || []) {
const verified = verifyManagedLegacyFile(operation, location, sourceRoot);
if (verified.destinationPath) removable.push(verified);
else if (verified.retainedPath) retainedPaths.push(verified.retainedPath);
}
return { removable, retainedPaths };
}
function removeLegacyFiles(removable, location, retainedPaths) {
const removedPaths = [];
for (const entry of removable) {
try {
if (!removeVerifiedLegacyFile(entry, location)) {
retainedPaths.push(entry.destinationPath);
continue;
}
removedPaths.push(entry.destinationPath);
removeEmptyParents(entry.destinationPath, location.targetRoot);
} catch (error) {
retainedPaths.push(entry.destinationPath);
if (error.retainedPath) retainedPaths.push(error.retainedPath);
}
}
return removedPaths;
}
function finalizeLegacyCleanup(location, retainedPaths, removedPaths) {
if (retainedPaths.length > 0) return false;
fs.rmSync(location.installStatePath, { force: true });
removedPaths.push(location.installStatePath);
try {
if (pathExists(location.targetRoot) && fs.readdirSync(location.targetRoot).length === 0) {
fs.rmdirSync(location.targetRoot);
}
} catch (_error) {
// Removing an empty legacy root is best effort after ownership is cleared.
}
return true;
}
function cleanupLegacyOpencodeInstall(plan) {
const location = getLegacyLocationForPlan(plan);
const emptyResult = emptyCleanupResult();
if (!location || !hasTrustedCanonicalState(plan)) return emptyResult;
const inspection = inspectLegacyOpencodeState(location);
if (inspection.status === 'unreadable') {
return {
...emptyResult,
detected: true,
retainedPaths: [location.targetRoot],
warnings: [inspection.error],
};
}
if (inspection.status !== 'valid') {
return emptyResult;
}
const { removable, retainedPaths } = classifyLegacyOperations(
inspection,
location,
plan.sourceRoot
);
const removedPaths = removeLegacyFiles(removable, location, retainedPaths);
const complete = finalizeLegacyCleanup(location, retainedPaths, removedPaths);
return {
detected: true,
complete,
removedPaths,
retainedPaths: [...new Set(retainedPaths)].sort(),
warnings: complete
? []
: ['Modified, unsupported, or unverifiable managed files remain under ~/.opencode and were preserved.'],
};
}
module.exports = {
cleanupLegacyOpencodeInstall,
getLegacyOpencodeLocation,
inspectLegacyOpencodeState,
removeVerifiedLegacyFile,
};
+3
View File
@@ -5,6 +5,7 @@ const {
createLegacyInstallPlan,
createManifestInstallPlan,
} = require('../install-executor');
const { resolveInvocationEnvironment } = require('../invocation-environment');
function createInstallPlanFromRequest(request, options = {}) {
if (!request || typeof request !== 'object') {
@@ -20,6 +21,7 @@ function createInstallPlanFromRequest(request, options = {}) {
excludeComponentIds: request.excludeComponentIds,
projectRoot: options.projectRoot,
homeDir: options.homeDir,
env: resolveInvocationEnvironment(options),
sourceRoot: options.sourceRoot,
});
}
@@ -32,6 +34,7 @@ function createInstallPlanFromRequest(request, options = {}) {
excludeComponentIds: request.excludeComponentIds,
projectRoot: options.projectRoot,
homeDir: options.homeDir,
env: resolveInvocationEnvironment(options),
claudeRulesDir: options.claudeRulesDir,
sourceRoot: options.sourceRoot,
});
+17
View File
@@ -0,0 +1,17 @@
'use strict';
function resolveInvocationEnvironment(options = {}) {
if (Object.prototype.hasOwnProperty.call(options, 'env')) {
return { ...(options.env || {}) };
}
if (typeof options.homeDir === 'string' && options.homeDir.trim() !== '') {
return {};
}
return { ...process.env };
}
module.exports = {
resolveInvocationEnvironment,
};
@@ -3,8 +3,10 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const { resolveOpencodeConfigRoot } = require('../../opencode-paths');
const { resolveInvocationEnvironment } = require('../../invocation-environment');
// OpenCode stores MCP servers under "mcp" in ~/.config/opencode/opencode.json.
// OpenCode stores MCP servers under "mcp" in its resolved configuration root.
// Shape differs from Claude/Codex:
// { type: "local"|"remote", command: ["npx","-y","pkg"], environment: {},
// enabled: bool, url: "https://..." }
@@ -38,11 +40,15 @@ function mapOpencodeServer(name, raw, configPath) {
function readOpencodeMcp(options = {}) {
const homeDir = options.homeDir || os.homedir();
const configRoot = resolveOpencodeConfigRoot({
homeDir,
env: resolveInvocationEnvironment(options),
});
const candidatePaths = options.configPath
? [options.configPath]
: [
path.join(homeDir, '.config', 'opencode', 'opencode.json'),
path.join(homeDir, '.config', 'opencode', 'config.json'),
path.join(configRoot, 'opencode.json'),
path.join(configRoot, 'config.json'),
path.join(homeDir, '.opencode.json')
];
+71 -14
View File
@@ -64,11 +64,60 @@ function pathsMatch(left, right) {
return canonicalPath(left) === canonicalPath(right);
}
function sameFileIdentity(left, right) {
return left.dev === right.dev
&& left.ino === right.ino
&& left.size === right.size
&& left.mtimeMs === right.mtimeMs
&& left.ctimeMs === right.ctimeMs;
}
function readRegularFileSnapshot(filePath) {
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
let descriptor;
try {
descriptor = fs.openSync(filePath, flags);
} catch (error) {
if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) return null;
throw error;
}
try {
const before = fs.fstatSync(descriptor);
if (!before.isFile()) {
throw new Error(`Refusing to read a non-file at ${filePath}.`);
}
const content = fs.readFileSync(descriptor);
const after = fs.fstatSync(descriptor);
const finalPathStat = fs.lstatSync(filePath);
if (
finalPathStat.isSymbolicLink()
|| !finalPathStat.isFile()
|| !sameFileIdentity(before, after)
|| !sameFileIdentity(after, finalPathStat)
) {
throw new Error(`Refusing to read a file that changed during validation: ${filePath}.`);
}
return { content, stat: after };
} finally {
fs.closeSync(descriptor);
}
}
function fingerprintFile(filePath) {
if (!fs.existsSync(filePath)) return { exists: false, sha256: null };
const snapshot = readRegularFileSnapshot(filePath);
if (!snapshot) return { exists: false, sha256: null };
return {
exists: true,
sha256: crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'),
sha256: crypto.createHash('sha256').update(snapshot.content).digest('hex'),
};
}
function fingerprintInstallStateValue(state) {
const content = Buffer.from(`${JSON.stringify(state, null, 2)}\n`);
return {
exists: true,
sha256: crypto.createHash('sha256').update(content).digest('hex'),
};
}
@@ -131,11 +180,11 @@ function readOwnedDestinations(plan, dependencies) {
} catch (error) {
throw new Error(`Refusing to trust managed install-state path: ${error.message}`);
}
if (!fs.existsSync(plan.installStatePath)) {
const initialFingerprint = fingerprintFile(plan.installStatePath);
if (!initialFingerprint.exists) {
return { destinations: new Set(), stateFingerprint: { exists: false, sha256: null } };
}
const readState = dependencies.readInstallState || require('./install-state').readInstallState;
const initialFingerprint = fingerprintFile(plan.installStatePath);
const state = readState(plan.installStatePath);
const validatedFingerprint = fingerprintFile(plan.installStatePath);
if (
@@ -185,11 +234,12 @@ function readOwnedDestinations(plan, dependencies) {
return { destinations, stateFingerprint: validatedFingerprint };
}
function assertMergeDestination(destinationPath) {
if (!fs.existsSync(destinationPath)) return null;
function assertMergeDestination(destinationPath, existingSnapshot = null) {
const snapshot = existingSnapshot || readRegularFileSnapshot(destinationPath);
if (!snapshot) return null;
let current;
try {
current = JSON.parse(fs.readFileSync(destinationPath, 'utf8'));
current = JSON.parse(snapshot.content.toString('utf8'));
} catch (error) {
throw new Error(`Cannot merge ECC configuration into invalid JSON at ${destinationPath}: ${error.message}`);
}
@@ -218,10 +268,11 @@ function findJsonConflicts(current, patch, prefix = '') {
function classifyManagedOperation(operation, ownedDestinations) {
const destinationPath = operation.destinationPath;
if (!fs.existsSync(destinationPath)) return 'create';
const destination = readRegularFileSnapshot(destinationPath);
if (!destination) return 'create';
const canonicalDestination = canonicalPath(destinationPath);
if (operation.kind === 'merge-json') {
const current = assertMergeDestination(destinationPath);
const current = assertMergeDestination(destinationPath, destination);
if (ownedDestinations.has(canonicalDestination)) return 'managed-json-update';
const conflicts = findJsonConflicts(current, operation.mergePayload);
if (conflicts.length > 0) {
@@ -235,9 +286,7 @@ function classifyManagedOperation(operation, ownedDestinations) {
if (
operation.kind === 'copy-file'
&& typeof operation.sourcePath === 'string'
&& fs.existsSync(operation.sourcePath)
&& fs.statSync(destinationPath).isFile()
&& fs.readFileSync(operation.sourcePath).equals(fs.readFileSync(destinationPath))
&& readRegularFileSnapshot(operation.sourcePath)?.content.equals(destination.content)
) {
return 'identical';
}
@@ -295,6 +344,9 @@ function preflightManagedPlan(plan, dependencies = {}) {
if (!plan || !Array.isArray(plan.operations)) {
throw new Error('A managed install plan with operations is required.');
}
if (typeof plan.installStatePath !== 'string' || plan.installStatePath.length === 0) {
throw new Error('A managed install-state path is required before preflight.');
}
const ownership = readOwnedDestinations(plan, dependencies);
const operations = plan.operations.map(operation => {
assertSafeInstallOperation(plan, operation);
@@ -320,13 +372,18 @@ async function applyPreflightedManagedPlan(entry) {
? entry.preview
: preflightManagedPlan(entry.preview.plan);
const ownedDestinations = new Set(preview.ownershipSnapshot.destinations);
const expectedStateFingerprint = preview.ownershipSnapshot.stateFingerprint;
let expectedStateFingerprint = preview.ownershipSnapshot.stateFingerprint;
let operationIndex = 0;
const assertStateUnchanged = () => (
assertInstallStateUnchanged(preview.plan, expectedStateFingerprint)
);
const prepareInstallStateWrite = ({ state }) => {
assertStateUnchanged();
expectedStateFingerprint = fingerprintInstallStateValue(state);
};
const result = require('./install-executor').applyInstallPlan(preview.plan, {
beforeInstallStateRead: assertStateUnchanged,
beforeOperationWrite({ operation }) {
assertStateUnchanged();
const expected = preview.operations[operationIndex];
@@ -345,7 +402,7 @@ async function applyPreflightedManagedPlan(entry) {
ownedDestinations.add(destination);
operationIndex += 1;
},
beforeInstallStateWrite: assertStateUnchanged,
beforeInstallStateWrite: prepareInstallStateWrite,
});
const { projectCanonicalInstallState } = require('./install-state-store-sync');
const installStateProjection = await projectCanonicalInstallState(result.statePreview);
+148 -15
View File
@@ -77,32 +77,60 @@ function readTarString(block, offset, length) {
return block.subarray(offset, offset + length).toString('utf8').replace(/\0.*$/, '');
}
function readTarOctal(block, offset, length) {
const field = block.subarray(offset, offset + length).toString('ascii');
const match = /^ *([0-7]+)[ \0]*$/.exec(field);
if (!match) throw new Error('Unsafe Nasiko archive: invalid tar size field.');
const size = Number.parseInt(match[1], 8);
if (!Number.isSafeInteger(size) || size < 0) {
throw new Error('Unsafe Nasiko archive: invalid tar size field.');
}
return size;
}
function extractQualifiedTarGzip(archiveBytes, expectedName) {
let tar;
try { tar = zlib.gunzipSync(archiveBytes, { maxOutputLength: MAX_BINARY_BYTES + 2048 }); }
catch (_error) { throw new Error('Nasiko archive is invalid or exceeds the decompressed size limit.'); }
let offset = 0;
let binary = null;
while (offset + 512 <= tar.length) {
let terminated = false;
while (offset < tar.length) {
if (offset + 512 > tar.length) throw new Error('Unsafe Nasiko archive: truncated tar header.');
const header = tar.subarray(offset, offset + 512);
if (header.every(byte => byte === 0)) break;
if (header.every(byte => byte === 0)) {
const terminatorEnd = offset + 1024;
if (
terminatorEnd > tar.length
|| !tar.subarray(offset + 512, terminatorEnd).every(byte => byte === 0)
|| !tar.subarray(terminatorEnd).every(byte => byte === 0)
) {
throw new Error('Unsafe Nasiko archive: incomplete terminator or nonzero trailing data.');
}
terminated = true;
break;
}
const name = readTarString(header, 0, 100);
const prefix = readTarString(header, 345, 155);
const type = String.fromCharCode(header[156] || 48);
const rawSize = readTarString(header, 124, 12).trim();
const size = Number.parseInt(rawSize || '0', 8);
const size = readTarOctal(header, 124, 12);
const start = offset + 512;
const end = start + size;
if (!Number.isSafeInteger(size) || size < 0 || end > tar.length) throw new Error('Nasiko archive is truncated.');
const paddedEnd = start + Math.ceil(size / 512) * 512;
if (!Number.isSafeInteger(end) || paddedEnd > tar.length) throw new Error('Nasiko archive is truncated.');
const payload = tar.subarray(start, end);
if (!tar.subarray(end, paddedEnd).every(byte => byte === 0)) {
throw new Error('Unsafe Nasiko archive: nonzero tar padding.');
}
const isBinary = !prefix && name === expectedName && (type === '0' || type === '\0');
const isAppleDouble = !prefix && name === `._${expectedName}` && type === '0' && size <= 1024 * 1024;
const isPaxMetadata = !prefix && name === `PaxHeader/${expectedName}` && type === 'x' && size <= 64 * 1024
&& !/(?:^|\n)(?:path|linkpath)=/i.test(payload.toString('utf8'));
if (isBinary && !binary && size > 0 && size <= MAX_BINARY_BYTES) binary = Buffer.from(payload);
else if (!isAppleDouble && !isPaxMetadata) throw new Error('Unsafe Nasiko archive: expected exactly one bounded regular binary file.');
offset = start + Math.ceil(size / 512) * 512;
offset = paddedEnd;
}
if (!terminated) throw new Error('Unsafe Nasiko archive: missing complete tar terminator.');
if (!binary) throw new Error('Unsafe Nasiko archive: expected exactly one bounded regular binary file.');
return binary;
}
@@ -229,26 +257,125 @@ function writeMetadataExclusive(metadataPath, metadata) {
fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
}
function acquireLifecycleLock(installDirectory, fileSystem = fs) {
const lockPath = path.join(installDirectory, '.ecc-nasiko-lifecycle.lock');
function sameFileIdentity(left, right) {
return left.dev === right.dev && left.ino === right.ino;
}
function processIsAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error.code !== 'ESRCH';
}
}
function inspectLifecycleLock(lockPath, fileSystem) {
const descriptor = fileSystem.openSync(lockPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
try {
const descriptorStats = fileSystem.fstatSync(descriptor, { bigint: true });
if (!descriptorStats.isFile() || descriptorStats.size <= 0n || descriptorStats.size > 4096n) return null;
const bytes = fileSystem.readFileSync(descriptor);
const pathStats = fileSystem.lstatSync(lockPath);
if (pathStats.isSymbolicLink() || !pathStats.isFile()) return null;
let metadata;
try { metadata = JSON.parse(bytes.toString('utf8')); } catch (_error) { return null; }
if (
!Number.isSafeInteger(metadata.pid)
|| metadata.pid <= 0
|| typeof metadata.startedAt !== 'string'
|| !Number.isFinite(Date.parse(metadata.startedAt))
) return null;
return { metadata, stats: descriptorStats };
} finally { fileSystem.closeSync(descriptor); }
}
function removeLockIfOwned(lockPath, expectedStats, fileSystem) {
let descriptor;
try {
descriptor = fileSystem.openSync(lockPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
const current = fileSystem.fstatSync(descriptor, { bigint: true });
const pathStats = fileSystem.lstatSync(lockPath);
if (!pathStats.isSymbolicLink() && pathStats.isFile() && current.isFile() && sameFileIdentity(current, expectedStats)) {
fileSystem.closeSync(descriptor);
descriptor = undefined;
fileSystem.rmSync(lockPath, { force: true });
return true;
}
} catch (error) {
if (error.code !== 'ENOENT' && error.code !== 'ELOOP') throw error;
} finally {
if (descriptor !== undefined) fileSystem.closeSync(descriptor);
}
return false;
}
function createLifecycleLock(lockPath, fileSystem) {
let descriptor;
try {
descriptor = fileSystem.openSync(lockPath, 'wx', 0o600);
fileSystem.writeFileSync(descriptor, `${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() })}\n`);
fileSystem.writeFileSync(descriptor, `${JSON.stringify({
pid: process.pid,
startedAt: new Date().toISOString(),
token: crypto.randomBytes(16).toString('hex'),
})}\n`);
fileSystem.fsyncSync(descriptor);
}
catch (error) {
if (error.code === 'EEXIST') throw new Error(`Another Nasiko lifecycle operation is already in progress; inspect ${lockPath} before recovering a stale lock.`);
if (descriptor !== undefined) {
try { fileSystem.closeSync(descriptor); } finally { fileSystem.rmSync(lockPath, { force: true }); }
const ownedStats = fileSystem.fstatSync(descriptor, { bigint: true });
try { fileSystem.closeSync(descriptor); } finally { removeLockIfOwned(lockPath, ownedStats, fileSystem); }
}
throw error;
}
const ownedStats = fileSystem.fstatSync(descriptor, { bigint: true });
let released = false;
return () => {
try { fileSystem.closeSync(descriptor); } finally { fileSystem.rmSync(lockPath, { force: true }); }
if (released) return;
released = true;
try { fileSystem.closeSync(descriptor); } finally { removeLockIfOwned(lockPath, ownedStats, fileSystem); }
};
}
function acquireLifecycleLock(installDirectory, fileSystem = fs, options = {}) {
const lockPath = path.join(installDirectory, '.ecc-nasiko-lifecycle.lock');
try {
return createLifecycleLock(lockPath, fileSystem);
} catch (error) {
if (error.code !== 'EEXIST') throw error;
}
let existing;
try { existing = inspectLifecycleLock(lockPath, fileSystem); }
catch (error) {
if (error.code === 'ENOENT') {
try { return createLifecycleLock(lockPath, fileSystem); }
catch (retryError) {
if (retryError.code === 'EEXIST') {
throw new Error(`Another Nasiko lifecycle operation won lock acquisition: ${lockPath}.`);
}
throw retryError;
}
}
throw error;
}
const isProcessAlive = options.isProcessAlive || processIsAlive;
if (!existing || isProcessAlive(existing.metadata.pid)) {
throw new Error(`Another Nasiko lifecycle operation is already in progress; inspect ${lockPath} before recovering a stale lock.`);
}
if (!removeLockIfOwned(lockPath, existing.stats, fileSystem)) {
throw new Error(`Nasiko lifecycle lock changed during stale-owner recovery: ${lockPath}.`);
}
try {
return createLifecycleLock(lockPath, fileSystem);
} catch (error) {
if (error.code === 'EEXIST') {
throw new Error(`Another Nasiko lifecycle operation won stale-lock recovery: ${lockPath}.`);
}
throw error;
}
}
async function installNasiko(options = {}, dependencies = {}) {
const version = options.version || 'v0.1.0';
const base = getQualifiedRelease(version, dependencies.platform || process.platform, dependencies.arch || process.arch);
@@ -316,6 +443,7 @@ function uninstallNasiko(options = {}, dependencies = {}) {
let binaryStaged = false;
let metadataStaged = false;
const rename = dependencies.rename || fs.renameSync;
const remove = dependencies.remove || (target => fs.rmSync(target));
try {
const status = (dependencies.inspectInstalled || inspectInstalledNasiko)(destination);
if (!status.installed) return { ...plan, dryRun: false, removed: false };
@@ -325,11 +453,16 @@ function uninstallNasiko(options = {}, dependencies = {}) {
rename(metadataPath, metadataTombstone);
metadataStaged = true;
const cleanupPending = [];
try { fs.rmSync(metadataTombstone); } catch (_error) { cleanupPending.push(metadataTombstone); }
try { remove(metadataTombstone); } catch (_error) { cleanupPending.push(metadataTombstone); }
metadataStaged = false;
try { fs.rmSync(binaryTombstone); } catch (_error) { cleanupPending.push(binaryTombstone); }
try { remove(binaryTombstone); } catch (_error) { cleanupPending.push(binaryTombstone); }
binaryStaged = false;
return { ...plan, dryRun: false, removed: true, cleanupPending };
if (cleanupPending.length > 0) {
const cleanupError = new Error(`Nasiko uninstall is incomplete; retained staged file(s): ${cleanupPending.join(', ')}. Remove these files before reinstalling.`);
cleanupError.cleanupPending = cleanupPending;
throw cleanupError;
}
return { ...plan, dryRun: false, removed: true, cleanupPending: [] };
} catch (error) {
if (metadataStaged && !fs.existsSync(metadataPath)) rename(metadataTombstone, metadataPath);
if (binaryStaged && !fs.existsSync(destination)) rename(binaryTombstone, destination);
+31
View File
@@ -0,0 +1,31 @@
'use strict';
const os = require('os');
const path = require('path');
const { resolveInvocationEnvironment } = require('./invocation-environment');
function configuredDirectory(environment, name) {
const value = environment && environment[name];
return typeof value === 'string' && value.trim() !== ''
? path.resolve(value.trim())
: null;
}
function resolveOpencodeConfigRoot(options = {}) {
const environment = resolveInvocationEnvironment(options);
const explicitRoot = configuredDirectory(environment, 'OPENCODE_CONFIG_DIR');
if (explicitRoot) {
return explicitRoot;
}
const xdgConfigRoot = configuredDirectory(environment, 'XDG_CONFIG_HOME');
if (xdgConfigRoot) {
return path.join(xdgConfigRoot, 'opencode');
}
return path.join(path.resolve(options.homeDir || os.homedir()), '.config', 'opencode');
}
module.exports = {
resolveOpencodeConfigRoot,
};
@@ -317,6 +317,7 @@ function reconcileCurrentInstallState(store, options = {}) {
homeDir: options.homeDir,
projectRoot: options.projectRoot,
targets: options.targets,
env: options.env,
});
let result = reconcileInstallStateProjections(store, records);
try {
+1
View File
@@ -72,6 +72,7 @@ function main() {
const records = discoverInstalledStates({
homeDir: process.env.HOME || os.homedir(),
env: process.env,
projectRoot: process.cwd(),
targets: options.targets,
}).filter(record => record.exists);
+1 -1
View File
@@ -13,7 +13,7 @@ const {
function helpText() {
return `
ECC Nasiko control-plane bridge
ECC experimental Nasiko CLI lifecycle bridge
Usage:
ecc nasiko status [--install-dir <absolute-path>] [--json]
+2
View File
@@ -81,6 +81,7 @@ async function main() {
const result = repairInstalledStates({
repoRoot: require('path').join(__dirname, '..'),
homeDir: process.env.HOME || os.homedir(),
env: process.env,
projectRoot: process.cwd(),
targets: options.targets,
dryRun: options.dryRun,
@@ -89,6 +90,7 @@ async function main() {
const { reconcileCanonicalInstallStates } = require('./lib/install-state-store-sync');
result.installStateProjection = await reconcileCanonicalInstallStates({
homeDir: process.env.HOME || os.homedir(),
env: process.env,
projectRoot: process.cwd(),
targets: options.targets,
});
+1
View File
@@ -467,6 +467,7 @@ async function main() {
const installStateProjection = reconcileCurrentInstallState(store, {
homeDir: process.env.HOME || os.homedir(),
env: process.env,
projectRoot: process.cwd(),
});
const storedStatus = store.getStatus({
+73 -27
View File
@@ -1,17 +1,24 @@
#!/usr/bin/env node
const os = require('os');
const path = require('path');
const { uninstallInstalledStates } = require('./lib/install-lifecycle');
const { SUPPORTED_INSTALL_TARGETS } = require('./lib/install-manifests');
const { exitFeedbackLines } = require('./lib/feedback-links');
const { uninstallLegacyCodexSync } = require('./lib/codex-legacy-sync');
const {
legacyCodexSyncStateExists,
uninstallLegacyCodexSync,
} = require('./lib/codex-legacy-sync');
function showHelp(exitCode = 0) {
console.log(`
Usage: node scripts/uninstall.js [--target <${SUPPORTED_INSTALL_TARGETS.join('|')}>] [--legacy-codex-sync] [--dry-run] [--json]
Remove ECC-managed files recorded in install-state for the current context.
Use --legacy-codex-sync explicitly for the older sync-ecc-to-codex.sh installation.
When no install-state is found, the uninstaller also detects and removes
legacy sync-ecc-to-codex.sh artifacts, but only when a legacy ownership
manifest is present. Use --legacy-codex-sync to force the legacy path
explicitly, including marker-only AGENTS.md cleanup.
`);
process.exit(exitCode);
}
@@ -87,6 +94,30 @@ function printHuman(result) {
}
}
function legacyCodexSyncStateDetected(codexHome) {
return legacyCodexSyncStateExists(codexHome);
}
function printLegacy(result, dryRun) {
console.log('Legacy Codex sync cleanup summary:\n');
console.log(`Status: ${result.status.toUpperCase()}`);
const paths = dryRun ? result.plannedRemovals : result.removedPaths;
console.log(`${dryRun ? 'Planned changes' : 'Removed paths'}: ${paths.length}`);
if (result.retainedPaths.length > 0) {
console.log(`Retained paths: ${result.retainedPaths.length}`);
for (const retainedPath of result.retainedPaths) console.log(` - ${retainedPath}`);
}
for (const warning of result.warnings) console.log(`Warning: ${warning}`);
}
function codexHomePath() {
return process.env.CODEX_HOME || path.join(process.env.HOME || os.homedir(), '.codex');
}
function includesCodexTarget(targets) {
return targets.length === 0 || targets.includes('codex');
}
async function main() {
try {
const options = parseArgs(process.argv);
@@ -97,41 +128,56 @@ async function main() {
if (options.legacyCodexSync && options.targets.length > 0) {
throw new Error('--legacy-codex-sync cannot be combined with --target');
}
const result = options.legacyCodexSync
? uninstallLegacyCodexSync({
codexHome: process.env.CODEX_HOME,
dryRun: options.dryRun,
})
: uninstallInstalledStates({
homeDir: process.env.HOME || os.homedir(),
projectRoot: process.cwd(),
targets: options.targets,
dryRun: options.dryRun,
});
if (!options.dryRun && !options.legacyCodexSync) {
const { reconcileCanonicalInstallStates } = require('./lib/install-state-store-sync');
result.installStateProjection = await reconcileCanonicalInstallStates({
let result;
let mode = 'install-state';
if (options.legacyCodexSync) {
result = uninstallLegacyCodexSync({
codexHome: codexHomePath(),
dryRun: options.dryRun,
});
mode = 'legacy-codex-sync';
} else {
result = uninstallInstalledStates({
homeDir: process.env.HOME || os.homedir(),
env: process.env,
projectRoot: process.cwd(),
targets: options.targets,
dryRun: options.dryRun,
});
if (
result.results.length === 0
&& includesCodexTarget(options.targets)
&& legacyCodexSyncStateDetected(codexHomePath())
) {
result = uninstallLegacyCodexSync({
codexHome: codexHomePath(),
dryRun: options.dryRun,
});
mode = 'legacy-codex-sync';
}
if (mode === 'install-state' && !options.dryRun) {
const { reconcileCanonicalInstallStates } = require('./lib/install-state-store-sync');
result.installStateProjection = await reconcileCanonicalInstallStates({
homeDir: process.env.HOME || os.homedir(),
env: process.env,
projectRoot: process.cwd(),
targets: options.targets,
});
}
}
const hasErrors = options.legacyCodexSync
const hasErrors = mode === 'legacy-codex-sync'
? result.status === 'partial'
: result.summary.errorCount > 0 || result.summary.partialCount > 0;
if (options.json) {
console.log(JSON.stringify(result, null, 2));
} else if (options.legacyCodexSync) {
console.log('Legacy Codex sync cleanup summary:\n');
console.log(`Status: ${result.status.toUpperCase()}`);
const paths = options.dryRun ? result.plannedRemovals : result.removedPaths;
console.log(`${options.dryRun ? 'Planned changes' : 'Removed paths'}: ${paths.length}`);
if (result.retainedPaths.length > 0) {
console.log(`Retained paths: ${result.retainedPaths.length}`);
for (const retainedPath of result.retainedPaths) console.log(` - ${retainedPath}`);
}
for (const warning of result.warnings) console.log(`Warning: ${warning}`);
} else if (mode === 'legacy-codex-sync') {
printLegacy(result, options.dryRun);
} else {
printHuman(result);
}
+4 -4
View File
@@ -1,12 +1,12 @@
---
name: nasiko-control-plane
description: Install, detect, and operate the optional Nasiko agent control plane through ECC with pinned artifacts, explicit consent, and telemetry and secrets boundaries.
description: Use the experimental Nasiko CLI lifecycle bridge for pinned installation, read-only status, and qualified uninstall with explicit consent and telemetry and secrets boundaries.
---
# Nasiko Control Plane
# Nasiko CLI Lifecycle Bridge
Use this skill when a user explicitly asks to install, inspect, or operate the
Nasiko control plane with ECC.
Use this skill when a user explicitly asks ECC to install, inspect, or remove
the qualified Nasiko CLI. This skill does not operate a Nasiko control plane.
## Safety contract
@@ -1,4 +1,4 @@
interface:
display_name: "Nasiko Control Plane"
short_description: "Safely install and inspect the optional Nasiko control plane"
display_name: "Nasiko CLI Bridge"
short_description: "Safely install and inspect the optional pinned Nasiko CLI"
default_prompt: "Use $nasiko-control-plane to inspect or explicitly install the pinned Nasiko CLI without enabling telemetry or exposing secrets."
-7
View File
@@ -1,7 +0,0 @@
.venv/
__pycache__/
*.py[cod]
results/*.md
.pytest_cache/
.coverage
uv.lock
+150 -3
View File
@@ -1,5 +1,5 @@
/**
* Contract and lifecycle tests for the opt-in Nasiko control-plane bridge.
* Contract and lifecycle tests for the opt-in Nasiko CLI lifecycle bridge.
*/
const assert = require('assert');
@@ -35,8 +35,31 @@ function sha256Digest(value) {
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
}
function tarGzipFixture({
name = 'nasiko',
payload = Buffer.from('x'),
sizeField = null,
padding = true,
terminatorBlocks = 2,
trailing = Buffer.alloc(0),
} = {}) {
const zlib = require('zlib');
const header = Buffer.alloc(512);
header.write(name, 0, 100, 'utf8');
header.write(sizeField || `${payload.length.toString(8).padStart(11, '0')}\0`, 124, 12, 'ascii');
header[156] = '0'.charCodeAt(0);
const paddingBytes = padding ? Buffer.alloc((512 - (payload.length % 512)) % 512) : Buffer.alloc(0);
return zlib.gzipSync(Buffer.concat([
header,
payload,
paddingBytes,
Buffer.alloc(terminatorBlocks * 512),
trailing,
]));
}
async function main() {
console.log('\n=== Testing Nasiko control-plane integration ===\n');
console.log('\n=== Testing Nasiko CLI lifecycle bridge ===\n');
const tests = [
['qualifies only pinned platform releases and rejects latest', () => {
@@ -102,6 +125,90 @@ async function main() {
assert.strictEqual(fs.existsSync(lockPath), false);
} finally { fs.rmSync(installRoot, { recursive: true, force: true }); }
}],
['recovers only locks whose recorded owner is confirmed dead', () => {
const { acquireLifecycleLock } = require('../../scripts/lib/nasiko-release');
const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-stale-lock-'));
const lockPath = path.join(installRoot, '.ecc-nasiko-lifecycle.lock');
try {
fs.writeFileSync(lockPath, `${JSON.stringify({
pid: 424242,
startedAt: '2026-08-25T00:00:00.000Z',
token: 'stale-owner',
})}\n`, { mode: 0o600 });
assert.throws(
() => acquireLifecycleLock(installRoot, fs, { isProcessAlive: () => true }),
/already in progress/i
);
const releaseLock = acquireLifecycleLock(installRoot, fs, { isProcessAlive: () => false });
assert.strictEqual(fs.existsSync(lockPath), true);
releaseLock();
assert.strictEqual(fs.existsSync(lockPath), false);
} finally { fs.rmSync(installRoot, { recursive: true, force: true }); }
}],
['refuses to recover malformed lifecycle-lock ownership', () => {
const { acquireLifecycleLock } = require('../../scripts/lib/nasiko-release');
const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-malformed-lock-'));
const lockPath = path.join(installRoot, '.ecc-nasiko-lifecycle.lock');
try {
fs.writeFileSync(lockPath, '{"pid":"unknown"}\n', { mode: 0o600 });
assert.throws(
() => acquireLifecycleLock(installRoot, fs, { isProcessAlive: () => false }),
/already in progress/i
);
} finally { fs.rmSync(installRoot, { recursive: true, force: true }); }
}],
['recovers a lock abandoned by a finished process', () => {
const { acquireLifecycleLock } = require('../../scripts/lib/nasiko-release');
const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-dead-process-lock-'));
const lockPath = path.join(installRoot, '.ecc-nasiko-lifecycle.lock');
const modulePath = path.join(REPO_ROOT, 'scripts', 'lib', 'nasiko-release.js');
try {
const child = spawnSync(process.execPath, ['-e',
`require(${JSON.stringify(modulePath)}).acquireLifecycleLock(${JSON.stringify(installRoot)});`
], { encoding: 'utf8' });
assert.strictEqual(child.status, 0, child.stderr);
assert.strictEqual(fs.existsSync(lockPath), true);
const releaseLock = acquireLifecycleLock(installRoot);
releaseLock();
assert.strictEqual(fs.existsSync(lockPath), false);
} finally { fs.rmSync(installRoot, { recursive: true, force: true }); }
}],
['a prior release callback never removes a replacement lifecycle lock', () => {
const { acquireLifecycleLock } = require('../../scripts/lib/nasiko-release');
const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-replaced-lock-'));
const lockPath = path.join(installRoot, '.ecc-nasiko-lifecycle.lock');
const displacedPath = `${lockPath}.displaced`;
try {
const releaseLock = acquireLifecycleLock(installRoot);
fs.renameSync(lockPath, displacedPath);
fs.writeFileSync(lockPath, '{"pid":1,"startedAt":"2026-08-25T00:00:00.000Z","token":"replacement"}\n');
releaseLock();
assert.strictEqual(fs.existsSync(lockPath), true);
} finally { fs.rmSync(installRoot, { recursive: true, force: true }); }
}],
['uses descriptor identity when Windows path stats disagree', () => {
const { acquireLifecycleLock } = require('../../scripts/lib/nasiko-release');
const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-windows-identity-'));
const lockPath = path.join(installRoot, '.ecc-nasiko-lifecycle.lock');
const windowsLikeFileSystem = {
...fs,
lstatSync: target => {
const stats = fs.lstatSync(target);
return {
...stats,
dev: Number(stats.dev) + 1,
isDirectory: () => stats.isDirectory(),
isFile: () => stats.isFile(),
isSymbolicLink: () => stats.isSymbolicLink(),
};
},
};
try {
const releaseLock = acquireLifecycleLock(installRoot, windowsLikeFileSystem);
releaseLock();
assert.strictEqual(fs.existsSync(lockPath), false);
} finally { fs.rmSync(installRoot, { recursive: true, force: true }); }
}],
['verifies manifest and blob digests before an atomic install', async () => {
const { installNasiko } = require('../../scripts/lib/nasiko-release');
const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-green-'));
@@ -190,6 +297,29 @@ async function main() {
fs.rmSync(installRoot, { recursive: true, force: true });
}
}],
['accepts one complete tar entry and rejects malformed tar boundaries', () => {
const { extractQualifiedTarGzip } = require('../../scripts/lib/nasiko-release');
assert.deepStrictEqual(
extractQualifiedTarGzip(tarGzipFixture(), 'nasiko'),
Buffer.from('x')
);
assert.throws(
() => extractQualifiedTarGzip(tarGzipFixture({ padding: false }), 'nasiko'),
/unsafe|truncated|terminator/i
);
assert.throws(
() => extractQualifiedTarGzip(tarGzipFixture({ trailing: Buffer.from([1]) }), 'nasiko'),
/unsafe|trailing/i
);
assert.throws(
() => extractQualifiedTarGzip(tarGzipFixture({ sizeField: '00000000001x' }), 'nasiko'),
/size|octal|unsafe/i
);
assert.throws(
() => extractQualifiedTarGzip(tarGzipFixture({ terminatorBlocks: 1 }), 'nasiko'),
/terminator|truncated|unsafe/i
);
}],
['read-only status never executes an unqualified explicit executable', () => {
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-status-'));
const executable = path.join(fixtureRoot, 'nasiko');
@@ -295,6 +425,23 @@ async function main() {
assert.deepStrictEqual(fs.readFileSync(path.join(installRoot, 'nasiko')), intruder);
} finally { fs.rmSync(installRoot, { recursive: true, force: true }); }
}],
['fails uninstall when staged tombstones cannot be removed', () => {
const { uninstallNasiko } = require('../../scripts/lib/nasiko-release');
const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-cleanup-failure-'));
const executable = path.join(installRoot, 'nasiko');
const metadataPath = path.join(installRoot, '.ecc-nasiko-install.json');
fs.writeFileSync(executable, 'qualified binary', { mode: 0o700 });
fs.writeFileSync(metadataPath, '{}', { mode: 0o600 });
try {
assert.throws(() => uninstallNasiko({ installDir: installRoot, yes: true }, {
platform: 'darwin',
arch: 'arm64',
inspectInstalled: () => ({ installed: true, qualified: true, version: 'v0.1.0' }),
remove: target => { throw new Error(`retained ${target}`); },
}), /incomplete|retained|cleanup/i);
assert.ok(fs.readdirSync(installRoot).some(name => name.includes('.remove-')));
} finally { fs.rmSync(installRoot, { recursive: true, force: true }); }
}],
['ships a canonical opt-in skill without silently bundling Nasiko', () => {
const skill = read('skills/nasiko-control-plane/SKILL.md');
assert.match(skill, /^name: nasiko-control-plane$/m);
@@ -321,7 +468,7 @@ async function main() {
{
id: 'capability:nasiko-control-plane',
family: 'capability',
description: 'Explicitly gated Nasiko control-plane installation, status, and agent-operations guidance with pinned artifact verification and opt-in telemetry boundaries.',
description: 'Experimental Nasiko CLI lifecycle bridge guidance for pinned installation, read-only status, qualified uninstall, and opt-in telemetry boundaries.',
modules: ['nasiko-control-plane'],
}
);
+59
View File
@@ -252,6 +252,47 @@ function findDriftCandidate(state, cursorRoot) {
return resolveManagedExistingPath(operation.destinationPath, cursorRoot).path;
}
function runTargetSmoke(options) {
parseJsonOutput(
options.runCli([
'install',
'--modules', 'workflow-quality',
'--target', options.target,
'--json',
]),
`${options.target} packed install`
);
const statePath = path.join(options.targetRoot, 'ecc-install-state.json');
const installedSkillPath = path.join(
options.targetRoot,
'skills',
'skill-comply',
'SKILL.md'
);
assert.ok(fs.existsSync(statePath), `${options.target} install-state must exist`);
assert.ok(
fs.existsSync(installedSkillPath),
`${options.target} must install skill-comply from the packed archive`
);
const doctor = parseJsonOutput(
options.runCli(['doctor', '--target', options.target, '--json']),
`${options.target} packed doctor`
);
assert.strictEqual(doctor.summary.errorCount, 0);
const uninstall = parseJsonOutput(
options.runCli(['uninstall', '--target', options.target, '--json']),
`${options.target} packed uninstall`
);
assert.strictEqual(uninstall.summary.errorCount, 0);
assert.ok(!fs.existsSync(statePath), `${options.target} uninstall must remove install-state`);
assert.ok(
!fs.existsSync(installedSkillPath),
`${options.target} uninstall must remove the installed skill`
);
}
function runLifecycle(options) {
assert.ok(fs.existsSync(options.packagePath), `release package does not exist: ${options.packagePath}`);
assertDownloadedArtifact(options.packagePath, process.cwd());
@@ -450,6 +491,22 @@ function runLifecycle(options) {
assert.strictEqual(statusAfterUninstall.installStateProjection.warningCount, 0);
assert.strictEqual(statusAfterUninstall.readiness.status, 'ok');
const antigravityRoot = path.join(projectDir, '.agents');
runTargetSmoke({
runCli,
target: 'antigravity',
targetRoot: antigravityRoot,
});
assert.ok(!fs.existsSync(path.join(projectDir, '.agent')));
const opencodeRoot = path.join(homeDir, '.config', 'opencode');
runTargetSmoke({
runCli,
target: 'opencode',
targetRoot: opencodeRoot,
});
assert.ok(!fs.existsSync(path.join(homeDir, '.opencode')));
return {
packageSha256: options.expectedSha256,
platform: process.platform,
@@ -469,6 +526,8 @@ function runLifecycle(options) {
'uninstall',
'status-uninstalled',
'sentinel-preserved',
'antigravity-install-doctor-uninstall',
'opencode-install-doctor-uninstall',
],
};
} finally {
@@ -69,6 +69,32 @@ for (const workflowPath of workflowPaths) {
}
});
test(`${workflowPath} selects reviewed release notes from the validated release version`, () => {
const verify = jobBlock(source, 'verify', 'lifecycle');
assert.match(verify, /RELEASE_VERSION="\$\{RELEASE_TAG#v\}"/);
assert.match(
verify,
/RELEASE_NOTES="docs\/releases\/\$\{RELEASE_VERSION\}\/release-notes\.md"/
);
assert.match(verify, /if \[ ! -f "\$RELEASE_NOTES" \]/);
assert.match(verify, /cp "\$RELEASE_NOTES" release_body\.md/);
assert.doesNotMatch(
verify,
/cp docs\/releases\/2\.2\.0\/release-notes\.md/,
'release workflows must not reuse 2.2.0 notes for later versions'
);
});
test(`${workflowPath} disables generated additions to reviewed release notes`, () => {
const publish = jobBlock(source, 'publish');
assert.match(
publish,
/body_path:\s*release_body\.md[\s\S]{0,160}generate_release_notes:\s*false/
);
assert.doesNotMatch(publish, /generate_release_notes:\s*(?:true|\$\{\{)/);
});
test(`${workflowPath} uploads the one packed tgz as the release artifact`, () => {
const verify = jobBlock(source, 'verify', 'lifecycle');
const packIndex = verify.indexOf('name: Pack npm artifact');
@@ -161,6 +187,16 @@ test('packed lifecycle invokes installed public bins, including setup help', ()
assert.doesNotMatch(lifecycleRunnerSource, /node_modules.*scripts.*ecc\.js/);
});
test('packed lifecycle validates canonical Antigravity and OpenCode installs', () => {
assert.match(lifecycleRunnerSource, /target:\s*'antigravity'/);
assert.match(lifecycleRunnerSource, /path\.join\(projectDir, '\.agents'\)/);
assert.match(lifecycleRunnerSource, /target:\s*'opencode'/);
assert.match(lifecycleRunnerSource, /path\.join\(homeDir, '\.config', 'opencode'\)/);
assert.match(lifecycleRunnerSource, /\['doctor', '--target', options\.target, '--json'\]/);
assert.match(lifecycleRunnerSource, /skill-comply[\s\S]*SKILL\.md/);
assert.match(lifecycleRunnerSource, /!fs\.existsSync\(installedSkillPath\)/);
});
test('packed lifecycle installs and verifies the opt-in Ito distribution surface', () => {
assert.match(
lifecycleRunnerSource,
+9 -9
View File
@@ -33,22 +33,22 @@ test('guide requires an installer with native Antigravity 2.0 support', () => {
);
});
test('guide states the temporary npm release boundary', () => {
test('guide uses the published 2.2 package without stale pre-release copy', () => {
assert.ok(
guide.includes('npm latest is currently `ecc-universal@2.1.0`'),
'Guide should identify the package version users receive from npm today'
guide.includes('npm view ecc-universal version'),
'Guide should let operators verify registry propagation before installation'
);
assert.ok(
guide.includes('ECC 2.2.0 has not been published to npm yet'),
'Guide should not imply that native Antigravity support is already published'
guide.includes('npx ecc-universal@2.2.0 install --profile minimal --target antigravity'),
'Guide should provide the pinned published-package installation path'
);
assert.ok(
guide.includes('current source checkout of `main` for native `.agents` support'),
'Guide should direct users to the main source checkout until ECC 2.2.0 is published'
!guide.includes('ECC 2.2.0 has not been published to npm yet'),
'The immutable 2.2 guide must not claim that 2.2 is unpublished'
);
assert.ok(
guide.includes('remove this release-status paragraph only after `ecc-universal@2.2.0` is published and registry readback succeeds'),
'Guide should retain a removal condition for the temporary release warning'
!guide.includes('npm latest is currently `ecc-universal@2.1.0`'),
'The immutable 2.2 guide must not advertise the old latest version'
);
});
+40
View File
@@ -0,0 +1,40 @@
'use strict';
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..', '..');
function read(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
}
const readme = read('README.md');
const changelog = read('CHANGELOG.md');
const releaseNotes = read('docs/releases/2.2.0/release-notes.md');
const nasikoSkill = read('skills/nasiko-control-plane/SKILL.md');
const modules = read('manifests/install-modules.json');
const components = read('manifests/install-components.json');
const staleReleaseCopy = [
/guided package setup is coming in .*2\.2/i,
/current npm\s+release,?\s+2\.1\.0/i,
/until .*2\.2\.0 is published/i,
/coming soon: guided setup in release 2\.2/i,
/release 2\.2 will support/i,
];
for (const pattern of staleReleaseCopy) {
assert.doesNotMatch(readme, pattern);
}
assert.match(readme, /ECC 2\.2 includes guided package setup/i);
assert.match(readme, /npm view ecc-universal version/);
for (const source of [changelog, releaseNotes, nasikoSkill, modules, components]) {
assert.doesNotMatch(source, /Nasiko integration/i);
assert.doesNotMatch(source, /operate the optional Nasiko agent control plane/i);
assert.match(source, /Nasiko CLI lifecycle bridge/i);
}
console.log('ECC 2.2 release copy: ok');
@@ -0,0 +1,22 @@
'use strict';
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const runbook = fs.readFileSync(
path.resolve(__dirname, '..', '..', 'docs', 'releases', '2.2.0', 'launch-runbook.md'),
'utf8'
);
assert.match(runbook, /Affaan.*only release operator/i);
assert.match(runbook, /npm view ecc-universal dist-tags --json/);
assert.match(runbook, /ecc-universal@2\.1\.0/);
assert.match(runbook, /git tag -s v2\.2\.0/);
assert.match(runbook, /git push origin refs\/tags\/v2\.2\.0/);
assert.match(runbook, /npm dist-tag add ecc-universal@2\.1\.0 latest/);
assert.match(runbook, /staged.*registry.*latest/is);
assert.match(runbook, /do not unpublish/i);
assert.match(runbook, /rollback/i);
console.log('ECC 2.2 launch runbook: ok');
+48
View File
@@ -7,6 +7,7 @@ const path = require('path');
const {
beginLegacySyncState,
detectLegacyCodexSync,
finalizeLegacySyncState,
recordLegacySyncPath,
rollbackLegacyCodexSync,
@@ -521,6 +522,53 @@ function runTests() {
fs.rmSync(homeDir, { recursive: true, force: true });
})) passed += 1; else failed += 1;
if (test('detectLegacyCodexSync surfaces unreadable AGENTS.md instead of reporting clean', () => {
// hasMarkerBlock previously swallowed every read/open error and returned false,
// which made detectLegacyCodexSync claim a clean home even when AGENTS.md was
// unreadable (EACCES, EMFILE, ...). The fix is to rethrow every error except
// ENOENT (a missing file is a legitimate "no marker" signal).
const homeDir = tempDir('legacy-codex-home-');
const codexHome = path.join(homeDir, '.codex');
const agentsPath = path.join(codexHome, 'AGENTS.md');
fs.mkdirSync(codexHome, { recursive: true });
fs.writeFileSync(agentsPath, '# User instructions\n<!-- BEGIN ECC -->\n<!-- END ECC -->\n');
// chmod 000 to make AGENTS.md unreadable. Skip when running as root because
// root bypasses mode bits and the test would not exercise the error path.
if (typeof process.getuid === 'function' && process.getuid() !== 0) {
fs.chmodSync(agentsPath, 0o000);
let threw = null;
try {
detectLegacyCodexSync(codexHome);
} catch (error) {
threw = error;
}
assert.ok(threw, 'detectLegacyCodexSync must propagate the read error');
assert.notStrictEqual(threw && threw.code, 'ENOENT');
fs.chmodSync(agentsPath, 0o600);
} else {
// Root path: simulate the same failure by replacing AGENTS.md with a
// directory — openRegularFileNoFollow then throws EACCES-on-open on
// Linux when the path resolves to a non-regular file.
fs.rmSync(agentsPath);
fs.mkdirSync(agentsPath);
let threw = null;
try {
detectLegacyCodexSync(codexHome);
} catch (error) {
threw = error;
}
assert.ok(threw, 'detectLegacyCodexSync must propagate the inspection error');
fs.rmSync(agentsPath, { recursive: true });
}
// Sanity check: a missing AGENTS.md is still treated as no-marker (not an error).
fs.rmSync(agentsPath, { force: true });
assert.strictEqual(detectLegacyCodexSync(codexHome), false);
fs.rmSync(homeDir, { recursive: true, force: true });
})) passed += 1; else failed += 1;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
+6 -1
View File
@@ -83,6 +83,11 @@ function runTests() {
assert.deepStrictEqual(kimi.scopes, [
{ id: 'project', targetId: 'kimi', root: './.kimi-code' },
]);
const opencode = getHarnessCapability('opencode');
assert.match(opencode.destinationResolution, /OPENCODE_CONFIG_DIR/);
assert.match(opencode.destinationResolution, /XDG_CONFIG_HOME/);
assert.match(opencode.destinationResolution, /~\/\.config\/opencode/);
})) passed++; else failed++;
if (test('keeps every advanced target attached to its registered root and scope', () => {
@@ -90,7 +95,7 @@ function runTests() {
cursor: ['project', './.cursor'],
antigravity: ['project', './.agents'],
gemini: ['project', './.gemini'],
opencode: ['home', '~/.opencode'],
opencode: ['home', '~/.config/opencode'],
codebuddy: ['project', './.codebuddy'],
joycode: ['project', './.joycode'],
qwen: ['home', '~/.qwen'],
+125 -12
View File
@@ -1,6 +1,7 @@
'use strict';
const assert = require('assert');
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
@@ -38,8 +39,14 @@ function createFixture(options = {}) {
const target = options.target || 'claude';
const targetRoot = target === 'claude'
? path.join(homeDir, '.claude')
: path.join(projectRoot, '.claude');
const installStatePath = path.join(targetRoot, 'ecc', 'install-state.json');
: path.join(projectRoot, target === 'cursor' ? '.cursor' : '.claude');
const installStatePath = target === 'cursor'
? path.join(targetRoot, 'ecc-install-state.json')
: path.join(targetRoot, 'ecc', 'install-state.json');
const adapterId = target === 'claude'
? 'claude-home'
: target === 'cursor' ? 'cursor-project' : 'claude-project';
const adapterKind = target === 'claude' ? 'home' : 'project';
const skillFiles = options.skillFiles || {
'SKILL.md': '# Current ECC skill\n',
'references/guide.md': '# Current ECC guide\n',
@@ -61,9 +68,9 @@ function createFixture(options = {}) {
schemaVersion: 'ecc.install.v1',
installedAt: new Date().toISOString(),
target: {
id: target === 'claude' ? 'claude-home' : 'claude-project',
id: adapterId,
target,
kind: target === 'claude' ? 'home' : 'project',
kind: adapterKind,
root: targetRoot,
installStatePath,
},
@@ -100,9 +107,9 @@ function createFixture(options = {}) {
mode: 'manifest',
target,
adapter: {
id: target === 'claude' ? 'claude-home' : 'claude-project',
id: adapterId,
target,
kind: target === 'claude' ? 'home' : 'project',
kind: adapterKind,
},
targetRoot,
installRoot: targetRoot,
@@ -130,6 +137,9 @@ function seedLegacyInstall(fixture, options = {}) {
? operation.sourceRelativePath.split(path.sep).join('\\')
: operation.sourceRelativePath,
destinationPath,
contentSha256: crypto.createHash('sha256')
.update(fs.readFileSync(destinationPath))
.digest('hex'),
};
});
@@ -233,12 +243,17 @@ function runTests() {
fs.mkdirSync(path.dirname(otherLegacyPath), { recursive: true });
fs.writeFileSync(otherSourcePath, '# Other source\n');
fs.writeFileSync(otherLegacyPath, '# Other legacy managed skill\n');
const otherLegacyOperation = createOperation(
'other-module',
fixture.sourceRoot,
otherSourceRelativePath,
otherLegacyPath
);
const otherLegacyOperation = {
...createOperation(
'other-module',
fixture.sourceRoot,
otherSourceRelativePath,
otherLegacyPath
),
contentSha256: crypto.createHash('sha256')
.update(fs.readFileSync(otherLegacyPath))
.digest('hex'),
};
writeInstallState(fixture.installStatePath, {
...fixture.plan.statePreview,
operations: [...legacyOperations, otherLegacyOperation],
@@ -433,6 +448,104 @@ function runTests() {
}
})) passed++; else failed++;
if (test('merges managed operations across selective installs for enabled and disabled migrations', () => {
for (const target of ['claude', 'cursor']) {
const fixture = createFixture({ target });
try {
applyInstallPlan(fixture.plan);
const extraSourceRelativePath = path.join('skills', 'extra-skill', 'SKILL.md');
const extraSourcePath = path.join(fixture.sourceRoot, extraSourceRelativePath);
const extraDestinationPath = path.join(
fixture.targetRoot,
'skills',
'extra-skill',
'SKILL.md'
);
fs.mkdirSync(path.dirname(extraSourcePath), { recursive: true });
fs.writeFileSync(extraSourcePath, '# Extra ECC skill\n');
const extraOperation = createOperation(
'skill-extra',
fixture.sourceRoot,
extraSourceRelativePath,
extraDestinationPath
);
const extraPlan = {
...fixture.plan,
operations: [extraOperation],
statePreview: {
...fixture.plan.statePreview,
request: {
...fixture.plan.statePreview.request,
modules: [],
includeComponents: ['skill-extra'],
},
resolution: {
selectedModules: [],
skippedModules: [],
},
operations: [extraOperation],
},
};
applyInstallPlan(extraPlan);
const stateAfterExtraInstall = readInstallState(fixture.installStatePath);
assert.ok(fixture.operations.every(operation => (
stateAfterExtraInstall.operations.some(recorded => (
recorded.destinationPath === operation.destinationPath
))
)));
assert.ok(stateAfterExtraInstall.operations.some(operation => (
operation.destinationPath === extraDestinationPath
)));
const updatedExtraOperation = {
...extraOperation,
moduleId: 'skill-extra-updated',
};
applyInstallPlan({
...extraPlan,
operations: [updatedExtraOperation],
statePreview: {
...extraPlan.statePreview,
operations: [updatedExtraOperation],
},
});
const stateAfterMetadataUpdate = readInstallState(fixture.installStatePath);
const updatedExtraRecords = stateAfterMetadataUpdate.operations.filter(operation => (
operation.destinationPath === extraDestinationPath
));
assert.strictEqual(updatedExtraRecords.length, 1);
assert.strictEqual(updatedExtraRecords[0].moduleId, 'skill-extra-updated');
const retry = applyInstallPlan(fixture.plan);
assert.deepStrictEqual(retry.skippedOperations, []);
const stateAfterRetry = readInstallState(fixture.installStatePath);
for (const originalOperation of fixture.operations) {
assert.strictEqual(
stateAfterRetry.operations.filter(operation => (
operation.destinationPath === originalOperation.destinationPath
)).length,
1,
`retry must record ${originalOperation.destinationPath} exactly once`
);
}
const retainedExtraRecords = stateAfterRetry.operations.filter(operation => (
operation.destinationPath === extraDestinationPath
));
assert.strictEqual(retainedExtraRecords.length, 1);
assert.strictEqual(retainedExtraRecords[0].moduleId, 'skill-extra-updated');
const uninstall = runUninstall(fixture);
assert.strictEqual(uninstall.summary.errorCount, 0);
assert.ok(fixture.operations.every(operation => !fs.existsSync(operation.destinationPath)));
assert.ok(!fs.existsSync(extraDestinationPath));
} finally {
cleanup(fixture.tempDir);
}
}
})) passed++; else failed++;
if (test('tracks a partial migration so retry and uninstall remain safe', () => {
const fixture = createFixture();
try {
+4
View File
@@ -55,6 +55,7 @@ function writeLegacySourceFixture(root) {
writeFile(root, path.join('rules', 'common', 'node_modules', 'ignored.md'), '# Ignored\n');
writeFile(root, path.join('rules', 'common', '.git', 'ignored.md'), '# Ignored\n');
writeFile(root, path.join('rules', 'common', '__pycache__', 'ignored.cpython-314.pyc'), 'ignored\n');
writeFile(root, path.join('rules', 'common', '.pytest_cache', 'ignored.md'), '# Ignored\n');
writeFile(root, path.join('rules', 'common', 'stray.pyc'), 'ignored\n');
writeFile(root, path.join('rules', 'common', 'stray.pyo'), 'ignored\n');
writeFile(root, path.join('rules', 'common', 'stray.pyd'), 'ignored\n');
@@ -116,6 +117,7 @@ function writeManifestSourceFixture(root) {
writeFile(root, path.join('src', 'node_modules', 'ignored.js'), 'console.log("ignored");\n');
writeFile(root, path.join('src', '.git', 'ignored.js'), 'console.log("ignored");\n');
writeFile(root, path.join('src', '__pycache__', 'ignored.cpython-314.pyc'), 'ignored\n');
writeFile(root, path.join('src', '.pytest_cache', 'ignored.md'), '# Ignored\n');
writeFile(root, path.join('src', 'stray.pyc'), 'ignored\n');
writeFile(root, path.join('src', 'stray.pyo'), 'ignored\n');
writeFile(root, path.join('src', 'stray.pyd'), 'ignored\n');
@@ -201,6 +203,7 @@ function runTests() {
assert.ok(!plan.operations.some(operation => operation.sourceRelativePath.includes('node_modules')));
assert.ok(!plan.operations.some(operation => operation.sourceRelativePath.includes('.git')));
assert.ok(!plan.operations.some(operation => operation.sourceRelativePath.includes('__pycache__')));
assert.ok(!plan.operations.some(operation => operation.sourceRelativePath.includes('.pytest_cache')));
assert.ok(!plan.operations.some(operation => /\.(?:pyc|pyo|pyd)$/.test(operation.sourceRelativePath)));
assert.deepStrictEqual(plan.statePreview.request.legacyLanguages, ['typescript', 'missing-lang', '../bad']);
assert.strictEqual(plan.statePreview.request.legacyMode, true);
@@ -371,6 +374,7 @@ function runTests() {
assert.ok(!normalizedSources.some(source => source.includes('node_modules')));
assert.ok(!normalizedSources.some(source => source.includes('.git')));
assert.ok(!normalizedSources.some(source => source.includes('__pycache__')));
assert.ok(!normalizedSources.some(source => source.includes('.pytest_cache')));
assert.ok(!normalizedSources.some(source => /\.(?:pyc|pyo|pyd)$/.test(source)));
assert.ok(plan.operations.some(operation => (
operation.sourceRelativePath === path.join('.claude-plugin', 'plugin.json')
+82 -1
View File
@@ -100,7 +100,7 @@ function writeCursorState(projectRoot, overrides = {}) {
}
function createOpencodeStateOptions(homeDir, overrides = {}) {
const targetRoot = overrides.targetRoot || path.join(homeDir, '.opencode');
const targetRoot = overrides.targetRoot || path.join(homeDir, '.config', 'opencode');
const installStatePath = overrides.installStatePath || path.join(targetRoot, 'ecc-install-state.json');
return {
@@ -357,6 +357,87 @@ function runTests() {
}
})) passed++; else failed++;
if (test('OpenCode discovery, doctor, and uninstall honor the explicit config root', () => {
const homeDir = createTempDir('install-lifecycle-opencode-home-');
const projectRoot = createTempDir('install-lifecycle-opencode-project-');
const targetRoot = path.join(homeDir, 'custom-opencode');
const installStatePath = path.join(targetRoot, 'ecc-install-state.json');
const sourceRelativePath = path.join('rules', 'common', 'coding-style.md');
const sourcePath = path.join(REPO_ROOT, sourceRelativePath);
const destinationPath = path.join(targetRoot, 'rules', 'common', 'coding-style.md');
const env = { OPENCODE_CONFIG_DIR: targetRoot };
try {
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
fs.copyFileSync(sourcePath, destinationPath);
writeState(installStatePath, {
adapter: { id: 'opencode-home', target: 'opencode', kind: 'home' },
targetRoot,
installStatePath,
request: {
profile: null,
modules: [],
includeComponents: [],
excludeComponents: [],
legacyLanguages: [],
legacyMode: false,
},
resolution: { selectedModules: [], skippedModules: [] },
operations: [{
kind: 'copy-file',
moduleId: 'rules-core',
sourcePath,
sourceRelativePath,
destinationPath,
strategy: 'preserve-relative-path',
ownership: 'managed',
scaffoldOnly: false,
contentSha256: crypto.createHash('sha256')
.update(fs.readFileSync(destinationPath))
.digest('hex'),
}],
source: {
repoVersion: CURRENT_PACKAGE_VERSION,
repoCommit: null,
manifestVersion: CURRENT_MANIFEST_VERSION,
},
});
const records = discoverInstalledStates({
homeDir,
projectRoot,
targets: ['opencode'],
env,
});
assert.strictEqual(records.length, 1);
assert.strictEqual(records[0].exists, true);
assert.strictEqual(records[0].installStatePath, installStatePath);
const doctor = buildDoctorReport({
repoRoot: REPO_ROOT,
homeDir,
projectRoot,
targets: ['opencode'],
env,
});
assert.strictEqual(doctor.results.length, 1);
assert.strictEqual(doctor.results[0].installStatePath, installStatePath);
const uninstall = uninstallInstalledStates({
homeDir,
projectRoot,
targets: ['opencode'],
env,
});
assert.strictEqual(uninstall.results[0].status, 'uninstalled');
assert.ok(!fs.existsSync(destinationPath));
assert.ok(!fs.existsSync(installStatePath));
} finally {
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
if (test('doctor reports missing managed files as an error', () => {
const homeDir = createTempDir('install-lifecycle-home-');
const projectRoot = createTempDir('install-lifecycle-project-');
@@ -0,0 +1,132 @@
'use strict';
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { applyInstallPlan } = require('../../scripts/lib/install/apply');
const { readInstallState } = require('../../scripts/lib/install-state');
const { uninstallInstalledStates } = require('../../scripts/lib/install-lifecycle');
let passed = 0;
let failed = 0;
function makePlan(root, moduleId, fileName) {
const targetRoot = path.join(root, '.cursor');
const installStatePath = path.join(targetRoot, 'ecc-install-state.json');
const sourcePath = path.join(root, 'source', moduleId, fileName);
const destinationPath = path.join(targetRoot, 'skills', moduleId, fileName);
fs.mkdirSync(path.dirname(sourcePath), { recursive: true });
fs.writeFileSync(sourcePath, `${moduleId}\n`);
const operation = {
kind: 'copy-file',
moduleId,
sourcePath,
sourceRelativePath: path.join('skills', moduleId, fileName),
destinationPath,
strategy: 'preserve-relative-path',
ownership: 'managed',
scaffoldOnly: false,
};
return {
mode: 'manifest',
target: 'cursor',
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
targetRoot,
installRoot: targetRoot,
installStatePath,
operations: [operation],
statePreview: {
schemaVersion: 'ecc.install.v1',
installedAt: new Date().toISOString(),
target: {
id: 'cursor-project',
target: 'cursor',
kind: 'project',
root: targetRoot,
installStatePath,
},
request: {
profile: null,
modules: [moduleId],
includeComponents: [],
excludeComponents: [],
legacyLanguages: [],
legacyMode: false,
},
resolution: { selectedModules: [moduleId], skippedModules: [] },
source: { manifestVersion: 1 },
operations: [operation],
},
warnings: [],
};
}
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-selective-reinstall-'));
try {
const first = makePlan(root, 'first-module', 'FIRST.md');
const second = makePlan(root, 'second-module', 'SECOND.md');
applyInstallPlan(first);
fs.writeFileSync(first.operations[0].destinationPath, 'user-modified\n');
applyInstallPlan(second);
const state = readInstallState(first.installStatePath);
assert.deepStrictEqual(
new Set(state.operations.map(operation => operation.moduleId)),
new Set(['first-module', 'second-module']),
'a later selective install must preserve earlier managed ownership'
);
const result = uninstallInstalledStates({ projectRoot: root, targets: ['cursor'] });
assert.strictEqual(result.summary.errorCount, 0);
assert.strictEqual(
fs.readFileSync(first.operations[0].destinationPath, 'utf8'),
'user-modified\n',
'selective reinstall must not claim modified retained content'
);
assert.ok(!fs.existsSync(second.operations[0].destinationPath));
console.log(' ✓ selective reinstall preserves cumulative ownership without claiming user changes');
passed += 1;
} catch (error) {
console.log(`${error.message}`);
failed += 1;
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
const partialRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-partial-non-claude-'));
try {
const copied = makePlan(partialRoot, 'copied-module', 'COPIED.md');
const missing = makePlan(partialRoot, 'missing-module', 'MISSING.md');
fs.rmSync(missing.operations[0].sourcePath);
const partialPlan = {
...copied,
operations: [copied.operations[0], missing.operations[0]],
statePreview: {
...copied.statePreview,
operations: [copied.operations[0], missing.operations[0]],
},
};
assert.throws(() => applyInstallPlan(partialPlan), /ENOENT/);
assert.ok(fs.existsSync(copied.operations[0].destinationPath));
const checkpoint = readInstallState(copied.installStatePath);
assert.ok(checkpoint.operations.some(operation => (
operation.destinationPath === copied.operations[0].destinationPath
)));
const result = uninstallInstalledStates({ projectRoot: partialRoot, targets: ['cursor'] });
assert.strictEqual(result.summary.errorCount, 0);
assert.ok(!fs.existsSync(copied.operations[0].destinationPath));
console.log(' ✓ failed non-Claude install checkpoints managed files for uninstall');
passed += 1;
} catch (error) {
console.log(`${error.message}`);
failed += 1;
} finally {
fs.rmSync(partialRoot, { recursive: true, force: true });
}
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
+73 -4
View File
@@ -6,12 +6,14 @@ const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const {
getInstallTargetAdapter,
listInstallTargetAdapters,
planInstallTargetScaffold,
} = require('../../scripts/lib/install-targets/registry');
const { resolveInvocationEnvironment } = require('../../scripts/lib/invocation-environment');
function normalizedRelativePath(value) {
return String(value || '').replace(/\\/g, '/');
@@ -629,8 +631,8 @@ function runTests() {
if (test('resolves qwen adapter root and install-state path from home dir', () => {
const adapter = getInstallTargetAdapter('qwen');
const homeDir = '/Users/example';
const root = adapter.resolveRoot({ homeDir });
const statePath = adapter.getInstallStatePath({ homeDir });
const root = adapter.resolveRoot({ homeDir, env: {} });
const statePath = adapter.getInstallStatePath({ homeDir, env: {} });
assert.strictEqual(adapter.id, 'qwen-home');
assert.strictEqual(adapter.target, 'qwen');
@@ -639,6 +641,70 @@ function runTests() {
assert.strictEqual(statePath, path.join(homeDir, '.qwen', 'ecc-install-state.json'));
})) passed++; else failed++;
if (test('opencode adapter honors config overrides in priority order', () => {
const adapter = getInstallTargetAdapter('opencode');
const homeDir = '/Users/example';
const xdgRoot = path.join(homeDir, 'xdg');
const explicitRoot = path.join(homeDir, 'custom-opencode');
assert.strictEqual(
adapter.resolveRoot({
homeDir,
env: {
XDG_CONFIG_HOME: xdgRoot,
OPENCODE_CONFIG_DIR: explicitRoot,
},
}),
path.resolve(explicitRoot)
);
assert.strictEqual(
adapter.resolveRoot({ homeDir, env: { XDG_CONFIG_HOME: xdgRoot } }),
path.join(path.resolve(xdgRoot), 'opencode')
);
assert.strictEqual(
adapter.getInstallStatePath({
homeDir,
env: { OPENCODE_CONFIG_DIR: explicitRoot },
}),
path.join(path.resolve(explicitRoot), 'ecc-install-state.json')
);
})) passed++; else failed++;
if (test('opencode adapter isolates an explicit home from ambient config overrides', () => {
const homeDir = '/Users/isolated';
const registryPath = path.join(__dirname, '..', '..', 'scripts', 'lib', 'install-targets', 'registry.js');
const child = spawnSync(process.execPath, ['-e', [
'const { getInstallTargetAdapter } = require(process.env.ECC_TEST_REGISTRY);',
'const root = getInstallTargetAdapter(\'opencode\').resolveRoot({ homeDir: process.env.ECC_TEST_HOME });',
'process.stdout.write(JSON.stringify(root));',
].join('\n')], {
encoding: 'utf8',
env: {
...process.env,
ECC_TEST_REGISTRY: registryPath,
ECC_TEST_HOME: homeDir,
OPENCODE_CONFIG_DIR: '/runner/global/opencode',
XDG_CONFIG_HOME: '/runner/global/xdg',
},
});
assert.strictEqual(child.status, 0, child.stderr);
assert.strictEqual(
JSON.parse(child.stdout),
path.join(path.resolve(homeDir), '.config', 'opencode')
);
})) passed++; else failed++;
if (test('invocation environments are immutable snapshots', () => {
const source = { OPENCODE_CONFIG_DIR: '/custom/opencode' };
const selected = resolveInvocationEnvironment({ env: source });
const ambient = resolveInvocationEnvironment();
assert.notStrictEqual(selected, source);
assert.notStrictEqual(ambient, process.env);
selected.OPENCODE_CONFIG_DIR = '/mutated';
assert.strictEqual(source.OPENCODE_CONFIG_DIR, '/custom/opencode');
})) passed++; else failed++;
if (test('qwen adapter supports lookup by target and adapter id', () => {
const byTarget = getInstallTargetAdapter('qwen');
const byId = getInstallTargetAdapter('qwen-home');
@@ -1073,8 +1139,11 @@ function runTests() {
assert.strictEqual(adapter.id, 'opencode-home');
assert.strictEqual(adapter.target, 'opencode');
assert.strictEqual(adapter.kind, 'home');
assert.strictEqual(root, path.join(homeDir, '.opencode'));
assert.strictEqual(statePath, path.join(homeDir, '.opencode', 'ecc-install-state.json'));
assert.strictEqual(root, path.join(path.resolve(homeDir), '.config', 'opencode'));
assert.strictEqual(
statePath,
path.join(path.resolve(homeDir), '.config', 'opencode', 'ecc-install-state.json')
);
})) passed++; else failed++;
if (test('opencode adapter validate reports an error when compiled plugin is missing', () => {
+73
View File
@@ -4,6 +4,7 @@ const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const {
MCP_SCHEMA_VERSION,
@@ -184,6 +185,78 @@ test('opencode reader splits command array and reads environment', () => {
assert.strictEqual(records.find(r => r.name === 'disabledtool').enabled, false);
});
test('opencode reader honors OPENCODE_CONFIG_DIR before XDG_CONFIG_HOME', () => {
const home = tmpHome();
const explicitRoot = path.join(home, 'explicit-opencode');
const xdgRoot = path.join(home, 'xdg');
for (const root of [explicitRoot, path.join(xdgRoot, 'opencode')]) {
fs.mkdirSync(root, { recursive: true });
fs.writeFileSync(path.join(root, 'opencode.json'), JSON.stringify({
mcp: {
[root === explicitRoot ? 'explicit' : 'xdg']: {
type: 'local',
command: ['node'],
},
},
}), 'utf8');
}
const explicit = readOpencodeMcp({
homeDir: home,
env: {
OPENCODE_CONFIG_DIR: explicitRoot,
XDG_CONFIG_HOME: xdgRoot,
},
});
assert.deepStrictEqual(explicit.map(record => record.name), ['explicit']);
const xdg = readOpencodeMcp({
homeDir: home,
env: { XDG_CONFIG_HOME: xdgRoot },
});
assert.deepStrictEqual(xdg.map(record => record.name), ['xdg']);
});
test('opencode reader isolates an explicit home from ambient config overrides', () => {
const home = tmpHome();
const configRoot = path.join(home, '.config', 'opencode');
const ambientRoot = path.join(home, 'runner-global-opencode');
fs.mkdirSync(configRoot, { recursive: true });
fs.mkdirSync(ambientRoot, { recursive: true });
fs.writeFileSync(path.join(configRoot, 'opencode.json'), JSON.stringify({
mcp: { isolated: { type: 'local', command: ['node'] } },
}), 'utf8');
fs.writeFileSync(path.join(ambientRoot, 'opencode.json'), JSON.stringify({
mcp: { leaked: { type: 'local', command: ['node'] } },
}), 'utf8');
const readerPath = path.join(
__dirname,
'..',
'..',
'scripts',
'lib',
'mcp-inventory',
'readers',
'opencode.js'
);
const child = spawnSync(process.execPath, ['-e', [
'const { readOpencodeMcp } = require(process.env.ECC_TEST_READER);',
'const names = readOpencodeMcp({ homeDir: process.env.ECC_TEST_HOME }).map(record => record.name);',
'process.stdout.write(JSON.stringify(names));',
].join('\n')], {
encoding: 'utf8',
env: {
...process.env,
ECC_TEST_READER: readerPath,
ECC_TEST_HOME: home,
OPENCODE_CONFIG_DIR: ambientRoot,
},
});
assert.strictEqual(child.status, 0, child.stderr);
assert.deepStrictEqual(JSON.parse(child.stdout), ['isolated']);
});
test('collectMcpInventory merges harnesses, detects fragmentation + drift, redacts secrets', () => {
const home = tmpHome();
// claude + opencode agree on github (consistent); codex github uses a
+46
View File
@@ -175,6 +175,52 @@ function writeManagedState(plan, overrides = {}) {
}
});
await test('rejects managed preflight plans without an install-state path', () => {
const root = tempDir('ecc-guided-missing-state-');
try {
const source = path.join(root, 'source.md');
writeFile(source, 'ecc\n');
const plan = managedPlan(root, [{
kind: 'copy-file',
sourcePath: source,
destinationPath: path.join(root, 'AGENTS.md'),
}]);
delete plan.installStatePath;
assert.throws(
() => preflightManagedPlan(plan),
/install-state path is required/i
);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
await test('rejects an identical copy source that is a symbolic link', () => {
if (process.platform === 'win32') return;
const root = tempDir('ecc-guided-source-symlink-');
try {
const realSource = path.join(root, 'real-source.md');
const linkedSource = path.join(root, 'linked-source.md');
const destination = path.join(root, 'AGENTS.md');
writeFile(realSource, 'same\n');
writeFile(destination, 'same\n');
fs.symlinkSync(realSource, linkedSource);
const plan = managedPlan(root, [{
kind: 'copy-file',
sourcePath: linkedSource,
destinationPath: destination,
}]);
assert.throws(
() => preflightManagedPlan(plan),
/symbolic link|regular non-symlink/i
);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
await test('rejects valid install-state from a different managed target identity', () => {
const root = tempDir('ecc-guided-forged-target-');
try {
+25
View File
@@ -0,0 +1,25 @@
function isPackEntry(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function getNpmPackEntry(output, packageName) {
const matchesPackage = value => (
isPackEntry(value) && value.name === packageName
);
if (Array.isArray(output)) {
return output.find(matchesPackage);
}
if (!isPackEntry(output)) {
return undefined;
}
if (matchesPackage(output[packageName])) {
return output[packageName];
}
return Object.values(output).find(matchesPackage);
}
module.exports = { getNpmPackEntry };
+68
View File
@@ -0,0 +1,68 @@
const assert = require('assert');
const { getNpmPackEntry } = require('./npm-pack-output');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(`${name}`);
passed += 1;
} catch (error) {
console.log(`${name}`);
console.error(` ${error.message}`);
failed += 1;
}
}
test('reads the npm 11 array response', () => {
const entry = getNpmPackEntry([
{ name: 'unrelated-package', filename: 'unrelated-package-1.0.0.tgz' },
{ name: 'ecc-universal', filename: 'ecc-universal-2.2.0.tgz' },
], 'ecc-universal');
assert.strictEqual(entry.filename, 'ecc-universal-2.2.0.tgz');
});
test('reads the npm 12 package-keyed response', () => {
const entry = getNpmPackEntry({
'ecc-universal': {
name: 'ecc-universal',
filename: 'ecc-universal-2.2.0.tgz',
},
}, 'ecc-universal');
assert.strictEqual(entry.filename, 'ecc-universal-2.2.0.tgz');
});
test('finds a requested package in a generic object response', () => {
const entry = getNpmPackEntry({
unrelated: { name: 'unrelated-package', filename: 'unrelated-package-1.0.0.tgz' },
target: { name: 'ecc-universal', filename: 'ecc-universal-2.2.0.tgz' },
}, 'ecc-universal');
assert.strictEqual(entry.filename, 'ecc-universal-2.2.0.tgz');
});
test('returns undefined for empty or malformed responses', () => {
assert.strictEqual(getNpmPackEntry([], 'ecc-universal'), undefined);
assert.strictEqual(getNpmPackEntry({}, 'ecc-universal'), undefined);
assert.strictEqual(getNpmPackEntry(null, 'ecc-universal'), undefined);
assert.strictEqual(
getNpmPackEntry([
{ name: 'unrelated-package', filename: 'unrelated-package-1.0.0.tgz' },
], 'ecc-universal'),
undefined
);
assert.strictEqual(
getNpmPackEntry({
unrelated: { name: 'unrelated-package', filename: 'unrelated-package-1.0.0.tgz' },
}, 'ecc-universal'),
undefined
);
});
console.log(`\nPassed: ${passed}`);
console.log(`Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
+389
View File
@@ -0,0 +1,389 @@
'use strict';
const assert = require('assert');
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { applyInstallPlan } = require('../../scripts/lib/install/apply');
const { createManifestInstallPlan } = require('../../scripts/lib/install-executor');
const {
buildDoctorReport,
discoverInstalledStates,
repairInstalledStates,
uninstallInstalledStates,
} = require('../../scripts/lib/install-lifecycle');
const { createInstallState, writeInstallState } = require('../../scripts/lib/install-state');
const {
cleanupLegacyOpencodeInstall,
getLegacyOpencodeLocation,
inspectLegacyOpencodeState,
removeVerifiedLegacyFile,
} = require('../../scripts/lib/install/opencode-legacy-migration');
const REPO_ROOT = path.join(__dirname, '..', '..');
const SOURCE_RELATIVE_PATH = path.join('skills', 'skill-comply', 'SKILL.md');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(`${name}`);
passed += 1;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
failed += 1;
}
}
function digest(content) {
return crypto.createHash('sha256').update(content).digest('hex');
}
function seedLegacyInstall(homeDir, options = {}) {
const targetRoot = path.join(homeDir, '.opencode');
const installStatePath = path.join(targetRoot, 'ecc-install-state.json');
const destinationPath = path.join(targetRoot, SOURCE_RELATIVE_PATH);
const sourceContent = fs.readFileSync(path.join(REPO_ROOT, SOURCE_RELATIVE_PATH));
const installedContent = options.modified ? Buffer.from('user-modified\n') : sourceContent;
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
fs.writeFileSync(destinationPath, installedContent);
const operation = {
kind: 'copy-file',
moduleId: 'workflow-quality',
sourceRelativePath: SOURCE_RELATIVE_PATH,
destinationPath,
strategy: 'preserve-relative-path',
ownership: 'managed',
scaffoldOnly: false,
contentSha256: digest(sourceContent),
};
const operations = [operation];
if (options.includeJsonOperation) {
const configPath = path.join(targetRoot, 'opencode.json');
fs.writeFileSync(configPath, JSON.stringify({ plugin: ['ecc'] }, null, 2) + '\n');
operations.push({
kind: 'merge-json',
moduleId: 'opencode-plugin',
sourceRelativePath: '.opencode/opencode.json',
destinationPath: configPath,
strategy: 'merge-json',
ownership: 'managed',
scaffoldOnly: false,
mergePayload: { plugin: ['ecc'] },
previousExists: false,
previousContent: null,
});
}
const state = createInstallState({
adapter: { id: 'opencode-home', target: 'opencode', kind: 'home' },
targetRoot,
installStatePath,
request: {
profile: null,
modules: ['workflow-quality'],
includeComponents: [],
excludeComponents: [],
legacyLanguages: [],
legacyMode: false,
},
resolution: { selectedModules: ['workflow-quality'], skippedModules: [] },
source: {
repoVersion: require('../../package.json').version,
repoCommit: 'legacy-opencode-test',
manifestVersion: require('../../manifests/install-modules.json').version,
},
operations,
});
writeInstallState(installStatePath, state);
return { targetRoot, installStatePath, destinationPath };
}
function canonicalPlan(homeDir, env) {
return createManifestInstallPlan({
sourceRoot: REPO_ROOT,
target: 'opencode',
moduleIds: ['workflow-quality'],
projectRoot: homeDir,
homeDir,
...(env ? { env } : {}),
exemptValidationCodes: ['opencode-plugin-not-built'],
});
}
console.log('\n=== Testing OpenCode legacy migration ===\n');
test('legacy inspection distinguishes absent, invalid, and unreadable state', () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-legacy-inspect-'));
try {
const location = getLegacyOpencodeLocation(homeDir);
assert.strictEqual(inspectLegacyOpencodeState(null).status, 'absent');
assert.strictEqual(inspectLegacyOpencodeState(location).status, 'absent');
fs.mkdirSync(location.targetRoot, { recursive: true });
fs.mkdirSync(location.installStatePath);
assert.strictEqual(inspectLegacyOpencodeState(location).status, 'invalid');
fs.rmSync(location.installStatePath, { recursive: true, force: true });
fs.writeFileSync(location.installStatePath, '{not-json', 'utf8');
const unreadable = inspectLegacyOpencodeState(location);
assert.strictEqual(unreadable.status, 'unreadable');
assert.ok(unreadable.error.includes(location.installStatePath));
assert.deepStrictEqual(cleanupLegacyOpencodeInstall(null), {
detected: false,
complete: false,
removedPaths: [],
retainedPaths: [],
warnings: [],
});
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
});
test('discovery and doctor surface the legacy managed root', () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-legacy-discover-'));
try {
const legacy = seedLegacyInstall(homeDir);
const records = discoverInstalledStates({ homeDir, projectRoot: homeDir, targets: ['opencode'] });
assert.strictEqual(records.length, 2);
assert.strictEqual(records[0].exists, false);
assert.strictEqual(records[1].installStatePath, legacy.installStatePath);
assert.strictEqual(records[1].legacyLayout, 'opencode');
const doctor = buildDoctorReport({
repoRoot: REPO_ROOT,
homeDir,
projectRoot: homeDir,
targets: ['opencode'],
});
assert.ok(doctor.results.some(result => (
result.issues.some(issue => issue.code === 'legacy-opencode-layout')
)));
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
});
test('uninstall removes unchanged legacy-managed files and preserves user content', () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-legacy-uninstall-'));
try {
const legacy = seedLegacyInstall(homeDir);
const sentinelPath = path.join(legacy.targetRoot, 'user.txt');
fs.writeFileSync(sentinelPath, 'keep\n');
const result = uninstallInstalledStates({ homeDir, projectRoot: homeDir, targets: ['opencode'] });
assert.strictEqual(result.summary.errorCount, 0, JSON.stringify(result));
assert.ok(!fs.existsSync(legacy.destinationPath));
assert.ok(!fs.existsSync(legacy.installStatePath));
assert.strictEqual(fs.readFileSync(sentinelPath, 'utf8'), 'keep\n');
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
});
test('a canonical install migrates unchanged legacy ownership', () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-legacy-apply-'));
try {
const legacy = seedLegacyInstall(homeDir);
const result = applyInstallPlan(canonicalPlan(homeDir));
assert.ok(result.applied);
assert.ok(fs.existsSync(path.join(homeDir, '.config', 'opencode', 'ecc-install-state.json')));
assert.ok(!fs.existsSync(legacy.installStatePath));
assert.ok(!fs.existsSync(legacy.destinationPath));
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
});
test('a canonical install migrates legacy ownership when its config root is overridden', () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-legacy-custom-root-'));
try {
const legacy = seedLegacyInstall(homeDir);
const configRoot = path.join(homeDir, 'custom', 'opencode');
const result = applyInstallPlan(canonicalPlan(homeDir, {
OPENCODE_CONFIG_DIR: configRoot,
}));
assert.ok(result.applied);
assert.ok(fs.existsSync(path.join(configRoot, 'ecc-install-state.json')));
assert.ok(!fs.existsSync(legacy.installStatePath));
assert.ok(!fs.existsSync(legacy.destinationPath));
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
});
test('legacy non-file operations do not block canonical cleanup or repair', () => {
const applyHome = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-legacy-json-apply-'));
const repairHome = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-legacy-json-repair-'));
try {
const legacyApply = seedLegacyInstall(applyHome, { includeJsonOperation: true });
applyInstallPlan(canonicalPlan(applyHome));
assert.ok(!fs.existsSync(legacyApply.installStatePath));
assert.ok(fs.existsSync(path.join(legacyApply.targetRoot, 'opencode.json')));
const legacyRepair = seedLegacyInstall(repairHome, { includeJsonOperation: true });
const result = repairInstalledStates({
repoRoot: REPO_ROOT,
homeDir: repairHome,
projectRoot: repairHome,
targets: ['opencode'],
});
const canonicalStatePath = path.join(
repairHome,
'.config',
'opencode',
'ecc-install-state.json'
);
assert.strictEqual(result.summary.errorCount, 0, JSON.stringify(result));
assert.ok(fs.existsSync(canonicalStatePath));
assert.ok(!fs.existsSync(legacyRepair.installStatePath));
assert.ok(fs.existsSync(path.join(legacyRepair.targetRoot, 'opencode.json')));
} finally {
fs.rmSync(applyHome, { recursive: true, force: true });
fs.rmSync(repairHome, { recursive: true, force: true });
}
});
test('repair migrates a legacy install while preserving modified legacy files', () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-legacy-repair-'));
try {
const legacy = seedLegacyInstall(homeDir, { modified: true });
const result = repairInstalledStates({
repoRoot: REPO_ROOT,
homeDir,
projectRoot: homeDir,
targets: ['opencode'],
});
assert.strictEqual(result.summary.errorCount, 0, JSON.stringify(result));
assert.ok(fs.existsSync(path.join(homeDir, '.config', 'opencode', 'ecc-install-state.json')));
assert.strictEqual(fs.readFileSync(legacy.destinationPath, 'utf8'), 'user-modified\n');
assert.ok(fs.existsSync(legacy.installStatePath));
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
});
test('migration never follows a legacy managed-file symlink', () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-legacy-symlink-'));
try {
const legacy = seedLegacyInstall(homeDir);
const victimPath = path.join(homeDir, 'victim.txt');
fs.writeFileSync(victimPath, 'do-not-delete\n');
fs.rmSync(legacy.destinationPath);
try {
fs.symlinkSync(victimPath, legacy.destinationPath);
} catch (error) {
if (process.platform === 'win32' && error.code === 'EPERM') {
console.log(' (symlink unsupported on this platform; skipping)');
return;
}
throw error;
}
const result = applyInstallPlan(canonicalPlan(homeDir));
assert.ok(result.warnings.some(warning => warning.includes('Legacy OpenCode migration')));
assert.strictEqual(fs.readFileSync(victimPath, 'utf8'), 'do-not-delete\n');
assert.ok(fs.lstatSync(legacy.destinationPath).isSymbolicLink());
assert.ok(fs.existsSync(legacy.installStatePath));
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
});
test('legacy cleanup never overwrites a file created during quarantine recovery', () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-legacy-no-clobber-'));
const targetRoot = path.join(homeDir, '.opencode');
const destinationPath = path.join(targetRoot, 'managed.md');
let quarantinePath = null;
let effectiveSafePath = destinationPath;
let userDescriptor = null;
const openDescriptors = [];
try {
fs.mkdirSync(targetRoot, { recursive: true });
fs.writeFileSync(destinationPath, 'managed-old\n');
const originalDescriptor = fs.openSync(destinationPath, 'r');
openDescriptors.push(originalDescriptor);
const originalStat = fs.fstatSync(originalDescriptor, { bigint: true });
let injected = false;
const fileSystem = new Proxy(fs, {
get(target, property) {
if (property === 'renameSync') {
return (sourcePath, targetPath) => {
fs.renameSync(sourcePath, targetPath);
effectiveSafePath = sourcePath;
quarantinePath = targetPath;
};
}
if (property === 'lstatSync') {
return (filePath, options) => {
if (!injected && quarantinePath && filePath === quarantinePath) {
injected = true;
const stat = fs.fstatSync(originalDescriptor, options);
userDescriptor = fs.openSync(effectiveSafePath, 'wx+', 0o600);
openDescriptors.push(userDescriptor);
fs.writeFileSync(userDescriptor, 'user-new\n');
return new Proxy(stat, {
get(statTarget, statProperty) {
if (statProperty === 'ino') return statTarget.ino + 1n;
const value = Reflect.get(statTarget, statProperty, statTarget);
return typeof value === 'function' ? value.bind(statTarget) : value;
},
});
}
return fs.lstatSync(filePath, options);
};
}
const value = Reflect.get(target, property, target);
return typeof value === 'function' ? value.bind(target) : value;
},
});
assert.throws(
() => removeVerifiedLegacyFile(
{ destinationPath, stat: originalStat },
{ targetRoot },
fileSystem
),
error => {
assert.strictEqual(error.code, 'EEXIST');
assert.strictEqual(error.retainedPath, quarantinePath);
return true;
}
);
const destinationStat = fs.lstatSync(destinationPath, { bigint: true });
const userStat = fs.fstatSync(userDescriptor, { bigint: true });
const retainedStat = fs.lstatSync(quarantinePath, { bigint: true });
const managedStat = fs.fstatSync(originalDescriptor, { bigint: true });
assert.strictEqual(destinationStat.dev, userStat.dev);
assert.strictEqual(destinationStat.ino, userStat.ino);
assert.strictEqual(retainedStat.dev, managedStat.dev);
assert.strictEqual(retainedStat.ino, managedStat.ino);
const userContent = Buffer.alloc(Buffer.byteLength('user-new\n'));
const managedContent = Buffer.alloc(Buffer.byteLength('managed-old\n'));
fs.readSync(userDescriptor, userContent, 0, userContent.length, 0);
fs.readSync(originalDescriptor, managedContent, 0, managedContent.length, 0);
assert.strictEqual(userContent.toString('utf8'), 'user-new\n');
assert.strictEqual(managedContent.toString('utf8'), 'managed-old\n');
} finally {
for (const descriptor of openDescriptors) {
try {
fs.closeSync(descriptor);
} catch (_error) {
// Best-effort fixture cleanup.
}
}
fs.rmSync(homeDir, { recursive: true, force: true });
if (quarantinePath) {
fs.rmSync(path.dirname(quarantinePath), { recursive: true, force: true });
}
}
});
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
+21
View File
@@ -28,6 +28,27 @@ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
let passed = 0;
let failed = 0;
if (
test('model selection inherits the user configured OpenCode provider', () => {
assert.ok(!Object.hasOwn(config, 'model'), 'Root config must not pin a provider-specific model');
assert.ok(!Object.hasOwn(config, 'small_model'), 'Root config must not pin a provider-specific small model');
assert.ok(
config.agent &&
typeof config.agent === 'object' &&
!Array.isArray(config.agent) &&
Object.keys(config.agent).length > 0,
'Reference config must define registered agents'
);
for (const [agentId, agent] of Object.entries(config.agent)) {
assert.ok(!Object.hasOwn(agent, 'model'), `Agent "${agentId}" must inherit the selected OpenCode model`);
}
})
)
passed++;
else failed++;
if (
test('plugin paths do not duplicate the .opencode directory', () => {
const plugins = config.plugin || [];
+46
View File
@@ -502,6 +502,52 @@ function runTests() {
}
})) passed += 1; else failed += 1;
if (test('runAutoUpdate gives legacy-only OpenCode migration guidance', () => {
const homeDir = createTempDir('auto-update-home-');
const projectRoot = createTempDir('auto-update-project-');
const repoRoot = createTempDir('auto-update-repo-');
try {
ensureFakeRepo(repoRoot);
const legacy = {
...makeRecord({
repoRoot,
homeDir,
projectRoot,
adapter: { id: 'opencode-home', target: 'opencode', kind: 'home' },
request: {
profile: null,
modules: ['workflow-quality'],
includeComponents: [],
excludeComponents: [],
legacyLanguages: [],
legacyMode: false,
},
resolution: { selectedModules: ['workflow-quality'], skippedModules: [] },
operations: [],
}),
installStatePath: path.join(homeDir, '.opencode', 'ecc-install-state.json'),
legacy: true,
legacyLayout: 'opencode',
};
const result = runAutoUpdate(
{ homeDir, projectRoot, repoRoot, dryRun: true },
{ discoverInstalledStates: () => [legacy] }
);
assert.deepStrictEqual(result.results, []);
assert.ok(result.warnings.some(warning => warning.includes(
'Run the OpenCode installer once to migrate it to the configured OpenCode directory'
)));
assert.ok(result.warnings.every(warning => !warning.includes('Antigravity')));
} finally {
cleanup(homeDir);
cleanup(projectRoot);
cleanup(repoRoot);
}
})) passed += 1; else failed += 1;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
+3 -1
View File
@@ -6,6 +6,7 @@ const assert = require("assert")
const fs = require("fs")
const path = require("path")
const { spawnSync } = require("child_process")
const { getNpmPackEntry } = require("../lib/npm-pack-output")
function runTest(name, fn) {
try {
@@ -54,7 +55,8 @@ function main() {
assert.strictEqual(result.status, 0, result.error?.message || result.stderr)
const packOutput = JSON.parse(result.stdout)
const packagedPaths = new Set(packOutput[0]?.files?.map((file) => file.path) ?? [])
const packEntry = getNpmPackEntry(packOutput, packageJson.name)
const packagedPaths = new Set(packEntry?.files?.map((file) => file.path) ?? [])
assert.ok(
packagedPaths.has(".opencode/dist/index.js"),
+4 -2
View File
@@ -11,6 +11,7 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const { getNpmPackEntry } = require('../lib/npm-pack-output');
const repoRoot = path.join(__dirname, '..', '..');
const packageJson = JSON.parse(
@@ -123,14 +124,15 @@ function getPackedFixture() {
['pack', '--json', '--ignore-scripts', '--pack-destination', directory]
);
const packOutput = JSON.parse(packResult.stdout);
const filename = packOutput[0]?.filename;
const packEntry = getNpmPackEntry(packOutput, packageJson.name);
const filename = packEntry?.filename;
assert.ok(filename, 'npm pack should report the archive filename');
packedFixture = {
archivePath: path.join(directory, filename),
directory,
publishedPaths: new Set(
packOutput[0]?.files?.map(file => file.path) || []
packEntry?.files?.map(file => file.path) || []
),
};
return packedFixture;
+8 -2
View File
@@ -6,6 +6,7 @@ const assert = require("assert")
const fs = require("fs")
const path = require("path")
const { spawnSync } = require("child_process")
const { getNpmPackEntry } = require("../lib/npm-pack-output")
function runTest(name, fn) {
try {
@@ -149,7 +150,8 @@ function main() {
assert.strictEqual(result.status, 0, result.error?.message || result.stderr)
const packOutput = JSON.parse(result.stdout)
const packagedPaths = new Set(packOutput[0]?.files?.map((file) => file.path) ?? [])
const packEntry = getNpmPackEntry(packOutput, packageJson.name)
const packagedPaths = new Set(packEntry?.files?.map((file) => file.path) ?? [])
for (const requiredPath of [
"scripts/catalog.js",
@@ -201,6 +203,7 @@ function main() {
"schemas/install-state.schema.json",
"schemas/memory.schema.json",
"skills/backend-patterns/SKILL.md",
"skills/skill-comply/SKILL.md",
"skills/unified-memory/SKILL.md",
]) {
assert.ok(
@@ -214,7 +217,6 @@ function main() {
"examples/CLAUDE.md",
"plugins/README.md",
"scripts/ci/catalog.js",
"skills/skill-comply/SKILL.md",
]) {
assert.ok(
!packagedPaths.has(excludedPath),
@@ -231,6 +233,10 @@ function main() {
!/\.py[cod]$/.test(packagedPath),
`npm pack should not include Python bytecode file ${packagedPath}`
)
assert.ok(
!packagedPath.includes(".pytest_cache/"),
`npm pack should not include pytest cache path ${packagedPath}`
)
}
}],
]
+46 -3
View File
@@ -51,6 +51,24 @@ for (const workflow of [
test(`${workflow} checks whether the tagged npm version already exists`, () => {
assert.match(content, /Check npm publish state/);
assert.match(content, /npm view "\$\{PACKAGE_NAME\}@\$\{PACKAGE_VERSION\}" version/);
assert.match(content, /E404/);
assert.match(content, /npm registry lookup failed/i);
});
test(`${workflow} requires the release commit to equal origin main`, () => {
assert.match(content, /git fetch origin main --no-tags/);
assert.match(content, /git rev-parse origin\/main/);
assert.match(content, /release commit.*origin\/main/i);
});
test(`${workflow} selects reviewed release notes from the release version`, () => {
assert.match(content, /RELEASE_VERSION="\$\{RELEASE_TAG#v\}"/);
assert.match(content, /docs\/releases\/\$\{RELEASE_VERSION\}\/release-notes\.md/);
});
test(`${workflow} publishes only the reviewed release notes`, () => {
assert.match(content, /body_path:\s*release_body\.md[\s\S]{0,160}generate_release_notes:\s*false/);
assert.doesNotMatch(content, /generate_release_notes:\s*(?:true|\$\{\{)/);
});
test(`${workflow} publishes new tag versions to npm`, () => {
@@ -59,19 +77,44 @@ for (const workflow of [
assert.match(content, /NODE_AUTH_TOKEN:\s*\$\{\{\s*secrets\.NPM_TOKEN\s*\}\}/);
});
test(`${workflow} creates the GitHub Release before publishing to npm`, () => {
test(`${workflow} stages stable npm versions before changing latest`, () => {
assert.match(content, /publish_tag:\s*\$\{\{ steps\.npm_publish_state\.outputs\.publish_tag \}\}/);
assert.match(content, /version\.includes\('-'\) \? 'next' : 'staged'/);
assert.match(content, /--tag "\$\{NPM_PUBLISH_TAG\}"/);
assert.match(content, /npm dist-tag add "\$\{PACKAGE_NAME\}@\$\{PACKAGE_VERSION\}" "\$\{NPM_DIST_TAG\}"/);
});
test(`${workflow} verifies registry bytes before promoting the final dist-tag`, () => {
const publishIndex = content.indexOf('name: Publish npm package');
const verifyIndex = content.indexOf('name: Verify published npm artifact');
const promoteIndex = content.indexOf('name: Promote verified npm version');
const releaseIndex = content.indexOf('name: Create GitHub Release');
assert.ok(publishIndex >= 0, 'missing npm publish step');
assert.ok(verifyIndex > publishIndex, 'registry verification must follow npm publish');
assert.ok(promoteIndex > verifyIndex, 'dist-tag promotion must follow registry verification');
assert.ok(releaseIndex > promoteIndex, 'GitHub Release must follow npm promotion');
assert.match(content, /npm view "\$\{PACKAGE_NAME\}@\$\{PACKAGE_VERSION\}" dist\.integrity/);
assert.match(content, /Published npm artifact does not match tested candidate/);
});
test(`${workflow} publishes to npm before creating the GitHub Release`, () => {
const releaseIndex = content.indexOf('name: Create GitHub Release');
const publishIndex = content.indexOf('name: Publish npm package');
assert.ok(releaseIndex >= 0, `${workflow} should create a GitHub Release`);
assert.ok(publishIndex >= 0, `${workflow} should publish the npm package`);
assert.ok(
releaseIndex < publishIndex,
`${workflow} should not publish to npm until GitHub Release creation has succeeded`
publishIndex < releaseIndex,
`${workflow} should publish the verified package before creating the GitHub Release`
);
});
}
test('reusable release workflow has no generated-notes input', () => {
assert.doesNotMatch(load('.github/workflows/reusable-release.yml'), /generate-notes:/);
});
if (failed > 0) {
console.log(`\nFailed: ${failed}`);
process.exit(1);
+168 -4
View File
@@ -23,6 +23,11 @@ const {
createInstallState,
writeInstallState,
} = require('../../scripts/lib/install-state');
const {
beginLegacySyncState,
recordLegacySyncPath,
finalizeLegacySyncState,
} = require('../../scripts/lib/codex-legacy-sync');
function createTempDir(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
@@ -39,10 +44,9 @@ function writeState(filePath, options) {
}
function run(args = [], options = {}) {
const env = {
...process.env,
HOME: options.homeDir || process.env.HOME,
};
const env = options.homeDir
? { ...process.env, HOME: options.homeDir, CODEX_HOME: path.join(options.homeDir, '.codex') }
: Object.fromEntries(Object.entries(process.env).filter(([key]) => key !== 'CODEX_HOME'))
try {
const stdout = execFileSync('node', [SCRIPT, ...args], {
@@ -355,6 +359,166 @@ function runTests() {
}
})) passed++; else failed++;
if (test('auto-detects legacy sync-ecc-to-codex.sh install and removes artifacts without touching conversations or unrelated config keys', () => {
const homeDir = createTempDir('uninstall-legacy-codex-home-');
const projectRoot = createTempDir('uninstall-legacy-codex-project-');
try {
const codexHome = path.join(homeDir, '.codex');
const configPath = path.join(codexHome, 'config.toml');
const agentsPath = path.join(codexHome, 'AGENTS.md');
const promptPath = path.join(codexHome, 'prompts', 'ecc-plan.md');
const conversationPath = path.join(codexHome, 'conversations', 'keep-me.md');
const userFilePath = path.join(codexHome, 'user-owned.txt');
fs.mkdirSync(codexHome, { recursive: true });
fs.writeFileSync(configPath, 'model = "user"\n');
fs.writeFileSync(agentsPath, '# User instructions\n');
fs.mkdirSync(path.dirname(promptPath), { recursive: true });
const statePath = beginLegacySyncState({
codexHome,
backupDir: path.join(codexHome, 'backups', 'ecc-test'),
});
recordLegacySyncPath({ statePath, filePath: configPath });
recordLegacySyncPath({ statePath, filePath: agentsPath });
recordLegacySyncPath({ statePath, filePath: promptPath });
fs.writeFileSync(configPath, 'model = "user"\napproval_policy = "on-request"\n');
fs.writeFileSync(
agentsPath,
'# User instructions\n\n<!-- BEGIN ECC -->\n# ECC managed\n<!-- END ECC -->\n'
);
fs.writeFileSync(promptPath, '# ECC generated prompt\n');
finalizeLegacySyncState({ statePath });
fs.mkdirSync(path.dirname(conversationPath), { recursive: true });
fs.writeFileSync(conversationPath, 'conversation history');
fs.writeFileSync(userFilePath, 'unrelated');
const uninstallResult = run([], { cwd: projectRoot, homeDir });
assert.strictEqual(uninstallResult.code, 0, uninstallResult.stderr);
assert.ok(!uninstallResult.stdout.includes('No ECC install-state files found'), uninstallResult.stdout);
assert.ok(uninstallResult.stdout.includes('Legacy Codex sync cleanup summary'), uninstallResult.stdout);
assert.ok(!fs.existsSync(promptPath));
assert.strictEqual(fs.readFileSync(configPath, 'utf8'), 'model = "user"\n');
assert.strictEqual(fs.readFileSync(agentsPath, 'utf8'), '# User instructions\n');
assert.strictEqual(fs.readFileSync(conversationPath, 'utf8'), 'conversation history');
assert.strictEqual(fs.readFileSync(userFilePath, 'utf8'), 'unrelated');
assert.ok(!fs.existsSync(statePath));
} finally {
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
if (test('does not misclassify a clean Codex home as a legacy install', () => {
const homeDir = createTempDir('uninstall-clean-codex-home-');
const projectRoot = createTempDir('uninstall-clean-codex-project-');
try {
const codexHome = path.join(homeDir, '.codex');
const configPath = path.join(codexHome, 'config.toml');
const conversationPath = path.join(codexHome, 'conversations', 'keep-me.md');
fs.mkdirSync(codexHome, { recursive: true });
fs.writeFileSync(configPath, 'model = "user"\n');
fs.mkdirSync(path.dirname(conversationPath), { recursive: true });
fs.writeFileSync(conversationPath, 'conversation history');
const uninstallResult = run([], { cwd: projectRoot, homeDir });
assert.strictEqual(uninstallResult.code, 0, uninstallResult.stderr);
assert.ok(uninstallResult.stdout.includes('No ECC install-state files found'), uninstallResult.stdout);
assert.ok(!uninstallResult.stdout.includes('Legacy Codex sync cleanup summary'), uninstallResult.stdout);
assert.strictEqual(fs.readFileSync(configPath, 'utf8'), 'model = "user"\n');
assert.strictEqual(fs.readFileSync(conversationPath, 'utf8'), 'conversation history');
} finally {
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
if (test('explicit --legacy-codex-sync on a clean home reports not-found without removing files', () => {
const homeDir = createTempDir('uninstall-legacy-clean-home-');
const projectRoot = createTempDir('uninstall-legacy-clean-project-');
try {
const codexHome = path.join(homeDir, '.codex');
const configPath = path.join(codexHome, 'config.toml');
fs.mkdirSync(codexHome, { recursive: true });
fs.writeFileSync(configPath, 'model = "user"\n');
const uninstallResult = run(['--legacy-codex-sync', '--json'], { cwd: projectRoot, homeDir });
assert.strictEqual(uninstallResult.code, 0, uninstallResult.stderr);
const parsed = JSON.parse(uninstallResult.stdout);
assert.strictEqual(parsed.status, 'not-found');
assert.deepStrictEqual(parsed.plannedRemovals, []);
assert.deepStrictEqual(parsed.retainedPaths, []);
assert.strictEqual(fs.readFileSync(configPath, 'utf8'), 'model = "user"\n');
} finally {
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
if (test('does not auto-fallback to a marker-only AGENTS.md without a legacy ownership manifest', () => {
const homeDir = createTempDir('uninstall-marker-only-codex-home-');
const projectRoot = createTempDir('uninstall-marker-only-codex-project-');
try {
const codexHome = path.join(homeDir, '.codex');
const configPath = path.join(codexHome, 'config.toml');
const agentsPath = path.join(codexHome, 'AGENTS.md');
const conversationPath = path.join(codexHome, 'conversations', 'keep-me.md');
fs.mkdirSync(codexHome, { recursive: true });
fs.writeFileSync(configPath, 'model = "user"\n');
fs.writeFileSync(
agentsPath,
'# User instructions\n\n<!-- BEGIN ECC -->\n# ECC managed\n<!-- END ECC -->\n'
);
fs.mkdirSync(path.dirname(conversationPath), { recursive: true });
fs.writeFileSync(conversationPath, 'conversation history');
const uninstallResult = run([], { cwd: projectRoot, homeDir });
assert.strictEqual(uninstallResult.code, 0, uninstallResult.stderr);
assert.ok(uninstallResult.stdout.includes('No ECC install-state files found'), uninstallResult.stdout);
assert.ok(!uninstallResult.stdout.includes('Legacy Codex sync cleanup summary'), uninstallResult.stdout);
assert.strictEqual(fs.readFileSync(agentsPath, 'utf8'), '# User instructions\n\n<!-- BEGIN ECC -->\n# ECC managed\n<!-- END ECC -->\n');
assert.strictEqual(fs.readFileSync(configPath, 'utf8'), 'model = "user"\n');
assert.strictEqual(fs.readFileSync(conversationPath, 'utf8'), 'conversation history');
} finally {
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
if (test('explicit --legacy-codex-sync removes a marker-only AGENTS.md block', () => {
const homeDir = createTempDir('uninstall-explicit-marker-codex-home-');
const projectRoot = createTempDir('uninstall-explicit-marker-codex-project-');
try {
const codexHome = path.join(homeDir, '.codex');
const agentsPath = path.join(codexHome, 'AGENTS.md');
fs.mkdirSync(codexHome, { recursive: true });
fs.writeFileSync(
agentsPath,
'# User instructions\n\n<!-- BEGIN ECC -->\n# ECC managed\n<!-- END ECC -->\n'
);
const uninstallResult = run(['--legacy-codex-sync'], { cwd: projectRoot, homeDir });
assert.strictEqual(uninstallResult.code, 0, uninstallResult.stderr);
assert.ok(uninstallResult.stdout.includes('Legacy Codex sync cleanup summary'), uninstallResult.stdout);
assert.ok(uninstallResult.stdout.includes('Status: UNINSTALLED'), uninstallResult.stdout);
assert.strictEqual(fs.readFileSync(agentsPath, 'utf8'), '# User instructions\n\n');
} finally {
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}