fix(install): harden ECC installer lifecycle

Make Antigravity 2.0 installs native and safely migrate legacy state. Ensure doctor, repair, status projection, repeat installs, legacy Codex sync, and uninstall converge without losing user files. Exclude Python bytecode and harden repo-scan bootstrap guidance.

Gate publishing and pull-request merges on one exact packed artifact completing install, repeat, drift, repair, status, and uninstall across Linux, macOS, and Windows.

Co-authored-by: lorencifernando-coder <lorenci.fernando@gmail.com>

Co-authored-by: Suliman Abdulrazzaq <suliman9000a@gmail.com>

Co-authored-by: Wu Shuwen <mikewushuwen@outlook.com>
This commit is contained in:
haelyra
2026-08-13 16:42:51 -04:00
co-authored by lorencifernando-coder Suliman Abdulrazzaq Wu Shuwen
parent eb49702651
commit 1db5c8ab4a
61 changed files with 6157 additions and 314 deletions
+7 -1
View File
@@ -79,13 +79,19 @@ not create a second scope or duplicate hook registration.
## Native plugin versus legacy managed sync
The commands above are the native Codex plugin path. The legacy managed sync
The commands above are the native Codex plugin path. The deprecated legacy managed sync
(`bash scripts/sync-ecc-to-codex.sh`) is a separate compatibility
path that merges files into `~/.codex`. It is not a native plugin install and
does not create a marketplace registration. Prefer the native path on current
Codex; use the legacy managed sync only when you intentionally need its copied
configuration layer.
New sync runs record a versioned ownership manifest. Inspect or remove that
layer explicitly with `ecc uninstall --legacy-codex-sync --dry-run`, followed
by `ecc uninstall --legacy-codex-sync`. Cleanup never targets conversation
history or native plugin caches. Older pre-manifest installs are cleaned
conservatively and unverifiable files are retained with warnings.
After install, `codex plugin list` is only a registration check. From an ECC
checkout, run the cache check to verify that the installed manifest can resolve
its referenced skills, MCP config, and assets:
+68
View File
@@ -108,6 +108,74 @@ jobs:
tests/
!tests/node_modules/
pack-installer:
name: Pack Installer Artifact
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
package_file: ${{ steps.pack.outputs.package_file }}
package_sha256: ${{ steps.pack.outputs.package_sha256 }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '20.x'
- name: Install dependencies
run: npm ci --ignore-scripts
- name: Pack exact installer 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')"
- name: Upload exact installer artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ecc-ci-installer-artifact
path: ${{ steps.pack.outputs.package_file }}
if-no-files-found: error
packed-install-lifecycle:
name: Packed Install (${{ matrix.os }})
needs: pack-installer
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- name: Checkout lifecycle test
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '20.x'
- name: Download exact installer artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ecc-ci-installer-artifact
path: release-artifacts
- name: Verify packed install lifecycle
env:
ECC_RELEASE_PACKAGE: release-artifacts/${{ needs.pack-installer.outputs.package_file }}
ECC_RELEASE_SHA256: ${{ needs.pack-installer.outputs.package_sha256 }}
run: node tests/ci/packed-artifact-lifecycle.js
validate:
name: Validate Components
runs-on: ubuntu-latest
+64 -4
View File
@@ -15,6 +15,8 @@ jobs:
already_published: ${{ steps.npm_publish_state.outputs.already_published }}
dist_tag: ${{ steps.npm_publish_state.outputs.dist_tag }}
package_file: ${{ steps.pack.outputs.package_file }}
package_sha256: ${{ steps.pack.outputs.package_sha256 }}
release_commit: ${{ steps.source.outputs.release_commit }}
steps:
- name: Checkout
@@ -23,6 +25,12 @@ jobs:
fetch-depth: 0
persist-credentials: false
- name: Pin release source
id: source
run: |
RELEASE_COMMIT=$(git rev-parse HEAD)
echo "release_commit=${RELEASE_COMMIT}" >> "$GITHUB_OUTPUT"
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
@@ -104,8 +112,7 @@ jobs:
id: pack
run: |
npm pack --json > npm-pack.json
PACKAGE_FILE=$(node -e "const fs = require('fs'); const data = JSON.parse(fs.readFileSync('npm-pack.json', 'utf8')); console.log(data[0].filename)")
echo "package_file=${PACKAGE_FILE}" >> "$GITHUB_OUTPUT"
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')"
- name: Upload release artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -116,10 +123,55 @@ jobs:
${{ steps.pack.outputs.package_file }}
if-no-files-found: error
- name: Verify existing npm artifact matches candidate
if: steps.npm_publish_state.outputs.already_published == 'true'
env:
ECC_RELEASE_PACKAGE: ${{ steps.pack.outputs.package_file }}
run: |
PACKAGE_NAME=$(node -p "require('./package.json').name")
PACKAGE_VERSION=$(node -p "require('./package.json').version")
REGISTRY_INTEGRITY=$(npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" dist.integrity)
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 registry integrity'); const actual = 'sha512-' + crypto.createHash('sha512').update(fs.readFileSync(process.env.ECC_RELEASE_PACKAGE)).digest('base64'); if (actual !== expected) throw new Error('Existing npm artifact does not match tested candidate')"
lifecycle:
name: Packed Lifecycle (${{ matrix.os }})
needs: verify
permissions:
contents: read
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout lifecycle test
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ needs.verify.outputs.release_commit }}
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '20.x'
- name: Download exact packed artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ecc-release-artifacts
path: release-artifacts
- name: Verify packed install lifecycle
env:
ECC_RELEASE_PACKAGE: release-artifacts/${{ needs.verify.outputs.package_file }}
ECC_RELEASE_SHA256: ${{ needs.verify.outputs.package_sha256 }}
run: node tests/ci/packed-artifact-lifecycle.js
publish:
name: Publish Release
runs-on: ubuntu-latest
needs: verify
needs: [verify, lifecycle]
permissions:
contents: write
id-token: write
@@ -136,6 +188,12 @@ jobs:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'
- name: Verify artifact before publish
env:
ECC_RELEASE_PACKAGE: ${{ needs.verify.outputs.package_file }}
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:
@@ -148,4 +206,6 @@ jobs:
if: needs.verify.outputs.already_published != 'true'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish "${{ needs.verify.outputs.package_file }}" --access public --provenance --tag "${{ needs.verify.outputs.dist_tag }}"
ECC_RELEASE_PACKAGE: ${{ needs.verify.outputs.package_file }}
NPM_DIST_TAG: ${{ needs.verify.outputs.dist_tag }}
run: npm publish "./${ECC_RELEASE_PACKAGE}" --access public --provenance --tag "${NPM_DIST_TAG}"
+65 -8
View File
@@ -38,15 +38,23 @@ jobs:
already_published: ${{ steps.npm_publish_state.outputs.already_published }}
dist_tag: ${{ steps.npm_publish_state.outputs.dist_tag }}
package_file: ${{ steps.pack.outputs.package_file }}
package_sha256: ${{ steps.pack.outputs.package_sha256 }}
release_commit: ${{ steps.source.outputs.release_commit }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
ref: ${{ inputs.tag }}
ref: refs/tags/${{ inputs.tag }}
persist-credentials: false
- name: Pin release source
id: source
run: |
RELEASE_COMMIT=$(git rev-parse HEAD)
echo "release_commit=${RELEASE_COMMIT}" >> "$GITHUB_OUTPUT"
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
@@ -62,9 +70,6 @@ jobs:
- name: Verify OpenCode package payload
run: node tests/scripts/build-opencode.test.js
- name: Verify OMP adapter payload
run: node tests/omp/omp-plugin.test.js
- name: Validate version tag
env:
INPUT_TAG: ${{ inputs.tag }}
@@ -124,8 +129,7 @@ jobs:
id: pack
run: |
npm pack --json > npm-pack.json
PACKAGE_FILE=$(node -e "const fs = require('fs'); const data = JSON.parse(fs.readFileSync('npm-pack.json', 'utf8')); console.log(data[0].filename)")
echo "package_file=${PACKAGE_FILE}" >> "$GITHUB_OUTPUT"
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')"
- name: Upload release artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -136,10 +140,55 @@ jobs:
${{ steps.pack.outputs.package_file }}
if-no-files-found: error
- name: Verify existing npm artifact matches candidate
if: steps.npm_publish_state.outputs.already_published == 'true'
env:
ECC_RELEASE_PACKAGE: ${{ steps.pack.outputs.package_file }}
run: |
PACKAGE_NAME=$(node -p "require('./package.json').name")
PACKAGE_VERSION=$(node -p "require('./package.json').version")
REGISTRY_INTEGRITY=$(npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" dist.integrity)
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 registry integrity'); const actual = 'sha512-' + crypto.createHash('sha512').update(fs.readFileSync(process.env.ECC_RELEASE_PACKAGE)).digest('base64'); if (actual !== expected) throw new Error('Existing npm artifact does not match tested candidate')"
lifecycle:
name: Packed Lifecycle (${{ matrix.os }})
needs: verify
permissions:
contents: read
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout lifecycle test
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ needs.verify.outputs.release_commit }}
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '20.x'
- name: Download exact packed artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ecc-release-artifacts
path: release-artifacts
- name: Verify packed install lifecycle
env:
ECC_RELEASE_PACKAGE: release-artifacts/${{ needs.verify.outputs.package_file }}
ECC_RELEASE_SHA256: ${{ needs.verify.outputs.package_sha256 }}
run: node tests/ci/packed-artifact-lifecycle.js
publish:
name: Publish Release
runs-on: ubuntu-latest
needs: verify
needs: [verify, lifecycle]
permissions:
contents: write
id-token: write
@@ -156,6 +205,12 @@ jobs:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'
- name: Verify artifact before publish
env:
ECC_RELEASE_PACKAGE: ${{ needs.verify.outputs.package_file }}
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:
@@ -169,4 +224,6 @@ jobs:
if: needs.verify.outputs.already_published != 'true'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish "${{ needs.verify.outputs.package_file }}" --access public --provenance --tag "${{ needs.verify.outputs.dist_tag }}"
ECC_RELEASE_PACKAGE: ${{ needs.verify.outputs.package_file }}
NPM_DIST_TAG: ${{ needs.verify.outputs.dist_tag }}
run: npm publish "./${ECC_RELEASE_PACKAGE}" --access public --provenance --tag "${NPM_DIST_TAG}"
+11 -2
View File
@@ -236,7 +236,7 @@ node scripts/codex/check-plugin-cache.js
Both add commands are idempotent. To refresh later, run `codex plugin marketplace upgrade ecc` followed by `codex plugin add ecc@ecc`. Codex stores one enabled plugin state in the active `CODEX_HOME`; it does not offer Claude's `user`, `project`, and `local` scopes. Its native hooks require an explicit trust decision and do not use Claude's four ECC hook profiles. Inside Codex, invoke `$configure-ecc` for the guided provider-aware flow.
The older `scripts/sync-ecc-to-codex.sh` path remains a separate compatibility option for users who intentionally want copied and merged configuration in `~/.codex`; it is not required for the native plugin. Run Codex once first so `~/.codex/config.toml` exists, then:
The older `scripts/sync-ecc-to-codex.sh` path is a deprecated compatibility option for users who intentionally need copied and merged configuration in `~/.codex`; it is not required for the native plugin. New sync runs write an ownership manifest so cleanup can preserve modified user files. Run Codex once first so `~/.codex/config.toml` exists, then:
```bash
git clone https://github.com/affaan-m/ECC.git
@@ -245,6 +245,15 @@ npm install
bash scripts/sync-ecc-to-codex.sh
```
To inspect or remove that legacy layer without touching Codex conversations or native plugin caches:
```bash
node scripts/ecc.js uninstall --legacy-codex-sync --dry-run
node scripts/ecc.js uninstall --legacy-codex-sync
```
Pre-manifest installations are handled conservatively: ECC removes its marked `AGENTS.md` block but preserves copied files it cannot prove it owns and reports them for review.
You can also open the ECC repository directly in Codex for a project-local setup. Codex reads the root `AGENTS.md` and the trusted project configuration in `.codex/` without a global sync. Do not add the native marketplace plugin on top of the sync flow.
For repo navigation, surface ownership, and PR diff packet guidance, read the [Codex ECC Navigation Map](docs/CODEX-NAVIGATION-GUIDE.md). See the [.codex plugin notes](.codex-plugin/README.md) for native lifecycle details.
@@ -2014,7 +2023,7 @@ Yes. ECC is cross-platform:
- **OpenCode**: Beta plugin integration in `.opencode/`; provider model selection and catalog parity remain 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**: Tightly integrated setup for workflows, skills, and flattened rules in `.agent/`. See [Antigravity Guide](docs/ANTIGRAVITY-GUIDE.md).
- **Antigravity**: Native Antigravity 2.0 setup for workflows, skills, custom agents, and flattened rules in `.agents/`. See [Antigravity Guide](docs/ANTIGRAVITY-GUIDE.md).
- **JoyCode / CodeBuddy**: Project-local selective install adapters for commands, agents, skills, and flattened rules. See [JoyCode Adapter Guide](docs/JOYCODE-GUIDE.md).
- **Qwen CLI**: Home-directory selective install adapter for commands, agents, skills, rules, and Qwen config. See [Qwen CLI Adapter Guide](docs/QWEN-GUIDE.md).
- **Zed**: Project-local selective install adapter for `.zed/settings.json`, flattened rules, commands, agents, and skills.
+77 -119
View File
@@ -1,156 +1,114 @@
# Antigravity Setup and Usage Guide
Google's [Antigravity](https://antigravity.dev) is an AI coding IDE that uses a `.agent/` directory convention for configuration. ECC provides first-class support for Antigravity through its selective install system.
Google Antigravity 2.0 discovers workspace customizations from the project-local
`.agents/` directory. ECC's Antigravity target installs native rules, workflows,
skills, and custom agents into that directory.
## Quick Start
## Quick start
```bash
# Install ECC with Antigravity target
./install.sh --target antigravity typescript
# Install the minimal profile
./install.sh --profile minimal --target antigravity
# Or with multiple language modules
# Compatibility syntax: common rules plus only these language packs
./install.sh --target antigravity typescript python go
```
This installs ECC components into your project's `.agent/` directory, ready for Antigravity to pick up.
Start a new Antigravity conversation after installing so the agent receives the
updated skill inventory.
## How the Install Mapping Works
## Native install mapping
ECC remaps its component structure to match Antigravity's expected layout:
| ECC source | Antigravity destination | Purpose |
|---|---|---|
| `rules/` | `.agents/rules/` | Workspace rules, flattened with collision-safe names |
| `commands/` | `.agents/workflows/` | User-invoked slash workflows |
| `skills/<name>/` | `.agents/skills/<name>/` | Agent Skills with a required `SKILL.md` |
| `agents/<name>.md` | `.agents/agents/<name>.md` | Custom main agents and subagents |
| ECC Source | Antigravity Destination | What It Contains |
|------------|------------------------|------------------|
| `rules/` | `.agent/rules/` | Language rules and coding standards (flattened) |
| `commands/` | `.agent/workflows/` | Slash commands become Antigravity workflows |
| `agents/` | `.agent/skills/` | Agent definitions become Antigravity skills |
ECC does not copy the repository's `.agents/` directory wholesale. That source
tree is Codex packaging and contains Codex-specific marketplace metadata. An
Antigravity plugin instead requires `.agents/plugins/<plugin-name>/plugin.json`.
> **Note on `.agents/` vs `.agent/` vs `agents/`**: The installer only handles three source paths explicitly: `rules` → `.agent/rules/`, `commands` → `.agent/workflows/`, and `agents` (no dot prefix) → `.agent/skills/`. The dot-prefixed `.agents/` directory in the ECC repo is a **static layout** for Codex/Antigravity skill definitions and `openai.yaml` configs — it is not directly mapped by the installer. Any `.agents/` path falls through to the default scaffold operation. If you want `.agents/skills/` content available in the Antigravity runtime, you must manually copy it to `.agent/skills/`.
Installed custom agent definitions are adapted to Antigravity's frontmatter:
Claude model tiers become `flash` or `pro`, and Claude tool names become their
Antigravity equivalents. Unsupported tool identifiers are never emitted because
Antigravity warns that invalid tool names can hang custom-agent execution.
### Key Differences from Claude Code
## Expected project tree
- **Rules are flattened**: Claude Code nests rules under subdirectories (`rules/common/`, `rules/typescript/`). Antigravity expects a flat `rules/` directory — the installer handles this automatically.
- **Commands become workflows**: ECC's `/command` files land in `.agent/workflows/`, which is Antigravity's equivalent of slash commands.
- **Agents become skills**: ECC agent definitions map to `.agent/skills/`, where Antigravity looks for skill configurations.
## Directory Structure After Install
```
```text
your-project/
── .agent/
├── rules/
│ ├── coding-standards.md
── testing.md
│ │ ├── security.md
│ └── typescript.md # language-specific rules
├── workflows/
── plan.md
├── code-review.md
│ │ ├── tdd.md
│ └── ...
── skills/
│ │ ├── planner.md
│ │ ├── code-reviewer.md
│ │ ├── tdd-guide.md
│ │ └── ...
│ └── ecc-install-state.json # tracks what ECC installed
── .agents/
├── rules/
│ ├── common-coding-style.md
── typescript-testing.md
├── workflows/
│ └── plan.md
├── skills/
── coding-standards/
└── SKILL.md
├── agents/
│ └── code-reviewer.md
── ecc-install-state.json
```
## The `openai.yaml` Agent Config
Each skill directory under `.agents/skills/` contains an `agents/openai.yaml` file at the path `.agents/skills/<skill-name>/agents/openai.yaml` that configures the skill for Antigravity:
```yaml
interface:
display_name: "API Design"
short_description: "REST API design patterns and best practices"
brand_color: "#F97316"
default_prompt: "Design REST API: resources, status codes, pagination"
policy:
allow_implicit_invocation: true
```
| Field | Purpose |
|-------|---------|
| `display_name` | Human-readable name shown in Antigravity's UI |
| `short_description` | Brief description of what the skill does |
| `brand_color` | Hex color for the skill's visual badge |
| `default_prompt` | Suggested prompt when the skill is invoked manually |
| `allow_implicit_invocation` | When `true`, Antigravity can activate the skill automatically based on context |
## Managing Your Installation
### Check What's Installed
## Verify the installation
```bash
node scripts/list-installed.js --target antigravity
```
### Repair a Broken Install
```bash
# First, diagnose what's wrong
node scripts/doctor.js --target antigravity
# Then, restore missing or drifted files
node scripts/repair.js --target antigravity
rg --files .agents/skills -g 'SKILL.md'
rg --files .agents/agents -g '*.md'
```
### Uninstall
In Antigravity, open **Settings > Customizations**, confirm that workspace
skills appear, start a new conversation, and request one by its exact name.
## Existing `.agent/` installations
Antigravity still reads legacy `.agent/rules` and `.agent/skills`, but ECC now
uses the canonical `.agents/` layout. Do not rename `.agent` manually because
ECC install-state contains absolute managed paths.
Rerun the same ECC install command after updating. ECC writes and verifies the
new `.agents/ecc-install-state.json` first, then removes only unchanged files
owned by the valid legacy state. Modified and unmanaged files remain in
`.agent/` and remain discoverable by doctor and uninstall until handled.
Preview lifecycle operations before applying them when desired:
```bash
node scripts/uninstall.js --target antigravity
node scripts/doctor.js --target antigravity
node scripts/repair.js --target antigravity --dry-run
node scripts/uninstall.js --target antigravity --dry-run
```
### Install State
The installer writes `.agent/ecc-install-state.json` to track which files ECC owns. This enables safe uninstall and repair — ECC will never touch files it didn't create.
## Adding Custom Skills for Antigravity
If you're contributing a new skill and want it available on Antigravity:
1. Create the skill under `skills/your-skill-name/SKILL.md` as usual
2. Add an agent definition at `agents/your-skill-name.md` — this is the path the installer maps to `.agent/skills/` at runtime, making your skill available in the Antigravity harness
3. Add the Antigravity agent config at `.agents/skills/your-skill-name/agents/openai.yaml` — this is a static repo layout consumed by Codex for implicit invocation metadata
4. Mirror the `SKILL.md` content to `.agents/skills/your-skill-name/SKILL.md` — this static copy is used by Codex and serves as a reference for Antigravity
5. Mention in your PR that you added Antigravity support
> **Key distinction**: The installer deploys `agents/` (no dot) → `.agent/skills/` — this is what makes skills available at runtime. The `.agents/` (dot-prefixed) directory is a separate static layout for Codex `openai.yaml` configs and is not auto-deployed by the installer.
See [CONTRIBUTING.md](../CONTRIBUTING.md) for the full contribution guide.
## Comparison with Other Targets
| Feature | Claude Code | Cursor | Codex | Antigravity |
|---------|-------------|--------|-------|-------------|
| Install target | `claude-home` | `cursor-project` | `codex-home` | `antigravity` |
| Config root | `~/.claude/` | `.cursor/` | `~/.codex/` | `.agent/` |
| Scope | User-level | Project-level | User-level | Project-level |
| Rules format | Nested dirs | Flat | Flat | Flat |
| Commands | `commands/` | N/A | N/A | `workflows/` |
| Agents/Skills | `agents/` | N/A | N/A | `skills/` |
| Install state | `ecc-install-state.json` | `ecc-install-state.json` | `ecc-install-state.json` | `ecc-install-state.json` |
## Troubleshooting
### Skills not loading in Antigravity
### Skills do not appear
- Verify the `.agent/` directory exists in your project root (not home directory)
- Check that `ecc-install-state.json` was created — if missing, re-run the installer
- Ensure files have `.md` extension and valid frontmatter
- A valid skill must be `.agents/skills/<name>/SKILL.md`.
- `.agent/.agents/skills` is an obsolete nested layout from older ECC builds.
- Start a new conversation after changing skill files.
### Rules not applying
### Rules do not apply
- Rules must be in `.agent/rules/`, not nested in subdirectories
- Run `node scripts/doctor.js --target antigravity` to verify the install
- Confirm the files are directly under `.agents/rules/`.
- Run doctor and inspect any missing or drifted managed-file warning.
### Workflows not available
### Workflows do not appear
- Antigravity looks for workflows in `.agent/workflows/`, not `commands/`
- If you manually copied ECC commands, rename the directory
- Confirm the files are under `.agents/workflows/`.
- Invoke a workflow with `/<workflow-name>` after restarting Antigravity.
## Related Resources
## Official Antigravity references
- [Selective Install Architecture](./SELECTIVE-INSTALL-ARCHITECTURE.md) — how the install system works under the hood
- [Selective Install Design](./SELECTIVE-INSTALL-DESIGN.md) — design decisions and target adapter contracts
- [CONTRIBUTING.md](../CONTRIBUTING.md) — how to contribute skills, agents, and commands
- [Skills](https://antigravity.google/docs/skills)
- [Rules and workflows](https://antigravity.google/docs/rules-workflows)
- [Custom agents and subagents](https://antigravity.google/docs/subagents)
- [Plugins](https://antigravity.google/docs/plugins)
See [CONTRIBUTING.md](../CONTRIBUTING.md) for ECC contribution guidance and
[SELECTIVE-INSTALL-ARCHITECTURE.md](SELECTIVE-INSTALL-ARCHITECTURE.md) for the
installer lifecycle contract.
+2 -2
View File
@@ -593,7 +593,7 @@ Suggested first adapters:
2. `cursor-project`
writes into `./.cursor/...`
3. `antigravity-project`
writes into `./.agent/...`
writes into `./.agents/...`
4. `codex-home`
later
5. `opencode-home`
@@ -668,7 +668,7 @@ Suggested path conventions:
- Cursor target:
`./.cursor/ecc-install-state.json`
- Antigravity target:
`./.agent/ecc-install-state.json`
`./.agents/ecc-install-state.json`
- future Codex target:
`~/.codex/ecc-install-state.json`
+1 -1
View File
@@ -1151,7 +1151,7 @@ Ja. ECC ist Cross-Platform:
- **OpenCode**: Vollständige Plugin-Unterstützung in `.opencode/`. Siehe [OpenCode-Unterstützung](#opencode-unterstützung).
- **Codex**: Erstklassige Unterstützung sowohl für die macOS-App als auch die CLI, mit Adapter-Drift-Guards und SessionStart-Fallback. Siehe PR [#257](https://github.com/affaan-m/ECC/pull/257).
- **GitHub Copilot (VS Code)**: Instruction- und Prompt-Schicht über `.github/copilot-instructions.md`, `.vscode/settings.json` und `.github/prompts/`. Siehe [GitHub-Copilot-Unterstützung](#github-copilot-unterstützung).
- **Antigravity**: Eng integriertes Setup für Workflows, Skills und abgeflachte Rules in `.agent/`. Siehe [Antigravity-Leitfaden](../../docs/ANTIGRAVITY-GUIDE.md).
- **Antigravity**: Eng integriertes Setup für Workflows, Skills und abgeflachte Rules in `.agents/`. Siehe [Antigravity-Leitfaden](../../docs/ANTIGRAVITY-GUIDE.md).
- **JoyCode / CodeBuddy**: Projektlokale Adapter für selektive Installation von Commands, Agents, Skills und abgeflachten Rules. Siehe [JoyCode-Adapter-Leitfaden](../../docs/JOYCODE-GUIDE.md).
- **Qwen CLI**: Adapter für selektive Installation im Home-Verzeichnis für Commands, Agents, Skills, Rules und Qwen-Konfiguration. Siehe [Qwen-CLI-Adapter-Leitfaden](../../docs/QWEN-GUIDE.md).
- **Zed**: Projektlokaler Adapter für selektive Installation von `.zed/settings.json`, abgeflachten Rules, Commands, Agents und Skills.
+1 -1
View File
@@ -1009,7 +1009,7 @@ Sí. ECC es multiplataforma:
- **OpenCode**: Soporte completo del plugin en `.opencode/`. Consulta [Soporte para OpenCode](#soporte-para-opencode).
- **Codex**: Soporte de primera clase para la app macOS y CLI, con guardias de deriva del adaptador y fallback de SessionStart. Consulta PR [#257](https://github.com/affaan-m/ECC/pull/257).
- **GitHub Copilot (VS Code)**: Capa de instrucciones y prompts mediante `.github/copilot-instructions.md`, `.vscode/settings.json` y `.github/prompts/`. Consulta [Soporte para GitHub Copilot](#soporte-para-github-copilot).
- **Antigravity**: Configuración estrechamente integrada para flujos de trabajo, skills y reglas aplanadas en `.agent/`. Consulta la [Guía de Antigravity](../ANTIGRAVITY-GUIDE.md).
- **Antigravity**: Configuración estrechamente integrada para flujos de trabajo, skills y reglas aplanadas en `.agents/`. Consulta la [Guía de Antigravity](../ANTIGRAVITY-GUIDE.md).
- **JoyCode / CodeBuddy**: Adaptadores de instalación selectiva locales al proyecto para comandos, agentes, skills y reglas aplanadas. Consulta la [Guía del Adaptador JoyCode](../JOYCODE-GUIDE.md).
- **Qwen CLI**: Adaptador de instalación selectiva en el directorio home para comandos, agentes, skills, reglas y configuración de Qwen. Consulta la [Guía del Adaptador Qwen CLI](../QWEN-GUIDE.md).
- **Zed**: Adaptador de instalación selectiva local al proyecto para `.zed/settings.json`, reglas aplanadas, comandos, agentes y skills.
+100 -9
View File
@@ -1,6 +1,6 @@
---
name: repo-scan
description: クロススタックのソースコード資産監査——各ファイルを分類し、埋め込まれたサードパーティライブラリを検出し、各モジュールに対してインタラクティブなHTMLレポートとともに実用的な4段階の判定を提供する
description: 固定されレビュー可能なコミットから外部の repo-scan スキルをインストールするブートストラップ用ポインター。クロススタックのソースコード資産監査を実行する前に repo-scan のインストールが必要な場合に使用する。この ECC ポインター自体は監査を実行しない
origin: community
---
@@ -18,18 +18,109 @@ origin: community
## インストール
```bash
# Fetch only the pinned commit for reproducibility
mkdir -p ~/.claude/skills/repo-scan
git init repo-scan
cd repo-scan
git remote add origin https://github.com/haibindev/repo-scan.git
git fetch --depth 1 origin 2742664
git checkout --detach FETCH_HEAD
cp -r . ~/.claude/skills/repo-scan
# Clone first so the pinned commit can be reviewed before installation
set -euo pipefail
REPO_SCAN_COMMIT=2742664ebcad1450c208eda0ae45d3c17fad5dd8
REPO_SCAN_INSTALL_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/repo-scan"
REPO_SCAN_INSTALL_PARENT="$(dirname "$REPO_SCAN_INSTALL_DIR")"
mkdir -p "$REPO_SCAN_INSTALL_PARENT"
REPO_SCAN_TMP="$(mktemp -d "$REPO_SCAN_INSTALL_PARENT/.repo-scan-install.XXXXXX")"
REPO_SCAN_TOKEN="${REPO_SCAN_TMP##*.}"
REPO_SCAN_STAGE="$REPO_SCAN_TMP/stage-$REPO_SCAN_TOKEN"
REPO_SCAN_BACKUP="$REPO_SCAN_TMP/backup-$REPO_SCAN_TOKEN"
REPO_SCAN_LOCK="$REPO_SCAN_INSTALL_PARENT/.repo-scan-install.lock"
REPO_SCAN_KEEP_TMP=0
REPO_SCAN_LOCK_HELD=0
REPO_SCAN_MV_HAS_NO_TARGET=0
cleanup_repo_scan_install() {
if [ "$REPO_SCAN_KEEP_TMP" -eq 0 ]; then
rm -rf -- "$REPO_SCAN_TMP"
fi
if [ "$REPO_SCAN_LOCK_HELD" -eq 1 ] && ! rmdir -- "$REPO_SCAN_LOCK"; then
printf 'Could not release installation lock at %s\n' "$REPO_SCAN_LOCK" >&2
fi
}
trap cleanup_repo_scan_install EXIT
mkdir "$REPO_SCAN_TMP/mv-probe-source"
if mv -T -- "$REPO_SCAN_TMP/mv-probe-source" \
"$REPO_SCAN_TMP/mv-probe-destination" 2>/dev/null; then
REPO_SCAN_MV_HAS_NO_TARGET=1
rmdir "$REPO_SCAN_TMP/mv-probe-destination"
else
rmdir "$REPO_SCAN_TMP/mv-probe-source"
fi
move_repo_scan_dir() {
REPO_SCAN_MOVE_SOURCE=$1
REPO_SCAN_MOVE_DESTINATION=$2
REPO_SCAN_MOVE_NAME=${REPO_SCAN_MOVE_SOURCE##*/}
if [ -e "$REPO_SCAN_MOVE_DESTINATION" ] || [ -L "$REPO_SCAN_MOVE_DESTINATION" ]; then
return 1
fi
if [ "$REPO_SCAN_MV_HAS_NO_TARGET" -eq 1 ]; then
mv -T -- "$REPO_SCAN_MOVE_SOURCE" "$REPO_SCAN_MOVE_DESTINATION"
return
fi
if ! mv -- "$REPO_SCAN_MOVE_SOURCE" "$REPO_SCAN_MOVE_DESTINATION"; then
return 1
fi
if [ -e "$REPO_SCAN_MOVE_DESTINATION/$REPO_SCAN_MOVE_NAME" ] || \
[ -L "$REPO_SCAN_MOVE_DESTINATION/$REPO_SCAN_MOVE_NAME" ]; then
if ! mv -- "$REPO_SCAN_MOVE_DESTINATION/$REPO_SCAN_MOVE_NAME" \
"$REPO_SCAN_MOVE_SOURCE"; then
REPO_SCAN_KEEP_TMP=1
printf 'Move conflict recovery failed; staged data remains at %s\n' \
"$REPO_SCAN_MOVE_DESTINATION/$REPO_SCAN_MOVE_NAME" >&2
fi
return 1
fi
}
git clone --filter=blob:none --no-checkout \
https://github.com/haibindev/repo-scan.git "$REPO_SCAN_TMP/source"
git -C "$REPO_SCAN_TMP/source" checkout --detach "$REPO_SCAN_COMMIT"
mkdir -p "$REPO_SCAN_STAGE"
git -C "$REPO_SCAN_TMP/source" archive "$REPO_SCAN_COMMIT" | \
tar -xf - -C "$REPO_SCAN_STAGE"
# Review "$REPO_SCAN_TMP/source" before approving installation.
printf 'Type install to replace %s after reviewing the pinned source: ' \
"$REPO_SCAN_INSTALL_DIR" >&2
read -r REPO_SCAN_CONFIRM
if [ "$REPO_SCAN_CONFIRM" != install ]; then
printf 'Installation cancelled.\n' >&2
exit 1
fi
if ! mkdir -- "$REPO_SCAN_LOCK" 2>/dev/null; then
printf 'Another repo-scan installation holds the lock at %s\n' \
"$REPO_SCAN_LOCK" >&2
exit 1
fi
REPO_SCAN_LOCK_HELD=1
if [ -e "$REPO_SCAN_INSTALL_DIR" ] || [ -L "$REPO_SCAN_INSTALL_DIR" ]; then
move_repo_scan_dir "$REPO_SCAN_INSTALL_DIR" "$REPO_SCAN_BACKUP"
fi
if ! move_repo_scan_dir "$REPO_SCAN_STAGE" "$REPO_SCAN_INSTALL_DIR"; then
if [ -e "$REPO_SCAN_BACKUP" ] || [ -L "$REPO_SCAN_BACKUP" ]; then
if [ -e "$REPO_SCAN_INSTALL_DIR" ] || [ -L "$REPO_SCAN_INSTALL_DIR" ]; then
REPO_SCAN_KEEP_TMP=1
printf 'Replacement failed and target was recreated; previous installation preserved at %s\n' \
"$REPO_SCAN_BACKUP" >&2
elif ! move_repo_scan_dir "$REPO_SCAN_BACKUP" "$REPO_SCAN_INSTALL_DIR"; then
REPO_SCAN_KEEP_TMP=1
printf 'Replacement and rollback failed; previous installation preserved at %s\n' \
"$REPO_SCAN_BACKUP" >&2
fi
fi
exit 1
fi
```
> エージェントスキルをインストールする前に、ソースコードをレビューしてください。
インストール後、エージェントハーネスを再読み込みしてから、`repo-scan` を再度呼び出してください。この ECC ポインターは外部スキルをインストールするだけで、スキャン自体は実行しません。
## コア機能
| 機能 | 説明 |
+1 -1
View File
@@ -586,7 +586,7 @@ cp -r everything-claude-code/rules/common ~/.claude/rules/common
- **Cursor**: `.cursor/`에 변환된 설정 제공
- **OpenCode**: `.opencode/`에 전체 플러그인 지원
- **Codex**: macOS 앱과 CLI 모두 퍼스트클래스 지원
- **Antigravity**: `.agent/`에 워크플로우, 스킬, 평탄화된 룰 통합
- **Antigravity**: `.agents/`에 워크플로우, 스킬, 에이전트, 평탄화된 룰 통합
- **Claude Code**: 네이티브 — 이것이 주 타겟입니다
</details>
+1 -1
View File
@@ -477,7 +477,7 @@ Sim. O ECC é multiplataforma:
- **Cursor**: Configs pré-traduzidas em `.cursor/`
- **OpenCode**: Suporte completo a plugins em `.opencode/`
- **Codex**: Suporte de primeira classe para app macOS e CLI
- **Antigravity**: Configuração integrada em `.agent/`
- **Antigravity**: Configuração integrada em `.agents/`
- **Claude Code**: Nativo — este é o alvo principal
</details>
+1 -1
View File
@@ -1082,7 +1082,7 @@ cp -r everything-claude-code/rules/common ~/.claude/rules/ecc/
- **Gemini CLI**: экспериментальная project-local поддержка через `.gemini/GEMINI.md` и общий plumbing установщика.
- **OpenCode**: полная поддержка плагина в `.opencode/`. См. [Поддержка OpenCode](#поддержка-opencode).
- **Codex**: первоклассная поддержка macOS app и CLI, с guards против adapter drift и SessionStart fallback. См. PR [#257](https://github.com/affaan-m/everything-claude-code/pull/257).
- **Antigravity**: плотная настройка для workflows, skills и flattened rules в `.agent/`. См. [Antigravity Guide](../ANTIGRAVITY-GUIDE.md).
- **Antigravity**: плотная настройка для workflows, skills, agents и flattened rules в `.agents/`. См. [Antigravity Guide](../ANTIGRAVITY-GUIDE.md).
- **Ненативные среды**: ручной fallback path для Grok и похожих интерфейсов. См. [Manual Adaptation Guide](../MANUAL-ADAPTATION-GUIDE.md).
- **Claude Code**: нативно — это основная цель.
</details>
+1 -1
View File
@@ -411,7 +411,7 @@ Evet. ECC çapraz platformdur:
- **Cursor**: `.cursor/` içinde önceden çevrilmiş config'ler. [Cursor IDE Desteği](../../README.md#cursor-ide-support) bölümüne bakın.
- **OpenCode**: `.opencode/` içinde tam plugin desteği. [OpenCode Desteği](../../README.md#opencode-support) bölümüne bakın.
- **Codex**: macOS app ve CLI için birinci sınıf destek. PR [#257](https://github.com/affaan-m/everything-claude-code/pull/257)'ye bakın.
- **Antigravity**: İş akışları, skill'ler ve `.agent/` içinde düzleştirilmiş rule'lar için sıkı entegre kurulum.
- **Antigravity**: İş akışları, skill'ler ve `.agents/` içinde düzleştirilmiş rule'lar için sıkı entegre kurulum.
- **Claude Code**: Native — bu birincil hedeftir.
</details>
+1 -1
View File
@@ -936,7 +936,7 @@ cp -r everything-claude-code/rules/common ~/.claude/rules/common
* **Cursor**: 预翻译的配置位于 `.cursor/`。参见 [Cursor IDE 支持](#cursor-ide-支持)。
* **OpenCode**: `.opencode/` 中的完整插件支持。参见 [OpenCode 支持](#opencode-支持)。
* **Codex**: 对 macOS 应用和 CLI 的一流支持,带有适配器漂移防护和 SessionStart 回退。参见 PR [#257](https://github.com/affaan-m/everything-claude-code/pull/257)。
* **Antigravity**: 为工作流、技能和扁平化规则紧密集成的设置,位于 `.agent/`。参见 [Antigravity 指南](../ANTIGRAVITY-GUIDE.md)。
* **Antigravity**: 为工作流、技能和扁平化规则紧密集成的设置,位于 `.agents/`。参见 [Antigravity 指南](../ANTIGRAVITY-GUIDE.md)。
* **Claude Code**: 原生支持 — 这是主要目标。
</details>
+100 -9
View File
@@ -1,6 +1,6 @@
---
name: repo-scan
description: 跨栈源代码资产审计——对每个文件进行分类,检测嵌入的第三方库,并为每个模块提供可操作的四级判定结果,附带交互式HTML报告
description: 用于从固定且可审查的提交安装外部 repo-scan 技能的引导指针。在运行跨栈源代码资产审计前需要安装 repo-scan 时使用;此 ECC 指针本身不执行审计
origin: community
---
@@ -18,18 +18,109 @@ origin: community
## 安装
```bash
# Fetch only the pinned commit for reproducibility
mkdir -p ~/.claude/skills/repo-scan
git init repo-scan
cd repo-scan
git remote add origin https://github.com/haibindev/repo-scan.git
git fetch --depth 1 origin 2742664
git checkout --detach FETCH_HEAD
cp -r . ~/.claude/skills/repo-scan
# Clone first so the pinned commit can be reviewed before installation
set -euo pipefail
REPO_SCAN_COMMIT=2742664ebcad1450c208eda0ae45d3c17fad5dd8
REPO_SCAN_INSTALL_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/repo-scan"
REPO_SCAN_INSTALL_PARENT="$(dirname "$REPO_SCAN_INSTALL_DIR")"
mkdir -p "$REPO_SCAN_INSTALL_PARENT"
REPO_SCAN_TMP="$(mktemp -d "$REPO_SCAN_INSTALL_PARENT/.repo-scan-install.XXXXXX")"
REPO_SCAN_TOKEN="${REPO_SCAN_TMP##*.}"
REPO_SCAN_STAGE="$REPO_SCAN_TMP/stage-$REPO_SCAN_TOKEN"
REPO_SCAN_BACKUP="$REPO_SCAN_TMP/backup-$REPO_SCAN_TOKEN"
REPO_SCAN_LOCK="$REPO_SCAN_INSTALL_PARENT/.repo-scan-install.lock"
REPO_SCAN_KEEP_TMP=0
REPO_SCAN_LOCK_HELD=0
REPO_SCAN_MV_HAS_NO_TARGET=0
cleanup_repo_scan_install() {
if [ "$REPO_SCAN_KEEP_TMP" -eq 0 ]; then
rm -rf -- "$REPO_SCAN_TMP"
fi
if [ "$REPO_SCAN_LOCK_HELD" -eq 1 ] && ! rmdir -- "$REPO_SCAN_LOCK"; then
printf 'Could not release installation lock at %s\n' "$REPO_SCAN_LOCK" >&2
fi
}
trap cleanup_repo_scan_install EXIT
mkdir "$REPO_SCAN_TMP/mv-probe-source"
if mv -T -- "$REPO_SCAN_TMP/mv-probe-source" \
"$REPO_SCAN_TMP/mv-probe-destination" 2>/dev/null; then
REPO_SCAN_MV_HAS_NO_TARGET=1
rmdir "$REPO_SCAN_TMP/mv-probe-destination"
else
rmdir "$REPO_SCAN_TMP/mv-probe-source"
fi
move_repo_scan_dir() {
REPO_SCAN_MOVE_SOURCE=$1
REPO_SCAN_MOVE_DESTINATION=$2
REPO_SCAN_MOVE_NAME=${REPO_SCAN_MOVE_SOURCE##*/}
if [ -e "$REPO_SCAN_MOVE_DESTINATION" ] || [ -L "$REPO_SCAN_MOVE_DESTINATION" ]; then
return 1
fi
if [ "$REPO_SCAN_MV_HAS_NO_TARGET" -eq 1 ]; then
mv -T -- "$REPO_SCAN_MOVE_SOURCE" "$REPO_SCAN_MOVE_DESTINATION"
return
fi
if ! mv -- "$REPO_SCAN_MOVE_SOURCE" "$REPO_SCAN_MOVE_DESTINATION"; then
return 1
fi
if [ -e "$REPO_SCAN_MOVE_DESTINATION/$REPO_SCAN_MOVE_NAME" ] || \
[ -L "$REPO_SCAN_MOVE_DESTINATION/$REPO_SCAN_MOVE_NAME" ]; then
if ! mv -- "$REPO_SCAN_MOVE_DESTINATION/$REPO_SCAN_MOVE_NAME" \
"$REPO_SCAN_MOVE_SOURCE"; then
REPO_SCAN_KEEP_TMP=1
printf 'Move conflict recovery failed; staged data remains at %s\n' \
"$REPO_SCAN_MOVE_DESTINATION/$REPO_SCAN_MOVE_NAME" >&2
fi
return 1
fi
}
git clone --filter=blob:none --no-checkout \
https://github.com/haibindev/repo-scan.git "$REPO_SCAN_TMP/source"
git -C "$REPO_SCAN_TMP/source" checkout --detach "$REPO_SCAN_COMMIT"
mkdir -p "$REPO_SCAN_STAGE"
git -C "$REPO_SCAN_TMP/source" archive "$REPO_SCAN_COMMIT" | \
tar -xf - -C "$REPO_SCAN_STAGE"
# Review "$REPO_SCAN_TMP/source" before approving installation.
printf 'Type install to replace %s after reviewing the pinned source: ' \
"$REPO_SCAN_INSTALL_DIR" >&2
read -r REPO_SCAN_CONFIRM
if [ "$REPO_SCAN_CONFIRM" != install ]; then
printf 'Installation cancelled.\n' >&2
exit 1
fi
if ! mkdir -- "$REPO_SCAN_LOCK" 2>/dev/null; then
printf 'Another repo-scan installation holds the lock at %s\n' \
"$REPO_SCAN_LOCK" >&2
exit 1
fi
REPO_SCAN_LOCK_HELD=1
if [ -e "$REPO_SCAN_INSTALL_DIR" ] || [ -L "$REPO_SCAN_INSTALL_DIR" ]; then
move_repo_scan_dir "$REPO_SCAN_INSTALL_DIR" "$REPO_SCAN_BACKUP"
fi
if ! move_repo_scan_dir "$REPO_SCAN_STAGE" "$REPO_SCAN_INSTALL_DIR"; then
if [ -e "$REPO_SCAN_BACKUP" ] || [ -L "$REPO_SCAN_BACKUP" ]; then
if [ -e "$REPO_SCAN_INSTALL_DIR" ] || [ -L "$REPO_SCAN_INSTALL_DIR" ]; then
REPO_SCAN_KEEP_TMP=1
printf 'Replacement failed and target was recreated; previous installation preserved at %s\n' \
"$REPO_SCAN_BACKUP" >&2
elif ! move_repo_scan_dir "$REPO_SCAN_BACKUP" "$REPO_SCAN_INSTALL_DIR"; then
REPO_SCAN_KEEP_TMP=1
printf 'Replacement and rollback failed; previous installation preserved at %s\n' \
"$REPO_SCAN_BACKUP" >&2
fi
fi
exit 1
fi
```
> 安装任何代理技能前,请先审查源码。
安装后,请重新加载智能体运行环境,然后再次调用 `repo-scan`。此 ECC 指针仅安装外部技能,本身不会执行扫描。
## 核心能力
| 能力 | 描述 |
+4 -5
View File
@@ -11,6 +11,7 @@
"dependencies": {
"@iarna/toml": "2.2.5",
"ajv": "8.20.0",
"js-yaml": "4.3.1",
"sql.js": "1.14.1"
},
"bin": {
@@ -531,7 +532,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/balanced-match": {
@@ -1474,10 +1474,9 @@
}
},
"node_modules/js-yaml": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"dev": true,
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"funding": [
{
"type": "github",
+7 -2
View File
@@ -134,6 +134,10 @@
"scripts/skill-create-output.js",
"scripts/status.js",
"scripts/sync-ecc-to-codex.sh",
"scripts/codex/legacy-sync-state.js",
"scripts/codex/install-global-git-hooks.sh",
"scripts/codex/check-codex-global-state.sh",
"scripts/codex-git-hooks/",
"scripts/work-items.js",
"scripts/uninstall.js",
"skills/agent-architecture-audit/",
@@ -469,6 +473,7 @@
"dependencies": {
"@iarna/toml": "2.2.5",
"ajv": "8.20.0",
"js-yaml": "4.3.1",
"sql.js": "1.14.1"
},
"pi": {
@@ -498,12 +503,12 @@
"overrides": {
"fast-uri": "3.1.5",
"markdown-it": "14.3.0",
"js-yaml": "4.3.0"
"js-yaml": "4.3.1"
},
"resolutions": {
"fast-uri": "3.1.5",
"markdown-it": "14.3.0",
"js-yaml": "4.3.0"
"js-yaml": "4.3.1"
},
"packageManager": "yarn@4.9.2+sha512.1fc009bc09d13cfd0e19efa44cbfc2b9cf6ca61482725eb35bbc5e257e093ebf4130db6dfe15d604ff4b79efd8e1e8e99b25fa7d0a6197c9f9826358d4d65c3c"
}
+19 -3
View File
@@ -179,11 +179,18 @@ function runAutoUpdate(options = {}, dependencies = {}) {
const homeDir = options.homeDir || process.env.HOME || os.homedir();
const projectRoot = options.projectRoot || process.cwd();
const requestedRepoRoot = options.repoRoot ? validateRepoRoot(options.repoRoot) : null;
const records = discover({
const discoveredRecords = discover({
homeDir,
projectRoot,
targets: options.targets
}).filter(record => record.exists);
});
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.',
]
: [];
const results = [];
if (records.length === 0) {
@@ -191,6 +198,7 @@ function runAutoUpdate(options = {}, dependencies = {}) {
dryRun: Boolean(options.dryRun),
repoRoot: requestedRepoRoot,
results,
warnings,
summary: {
checkedCount: 0,
updatedCount: 0,
@@ -233,6 +241,7 @@ function runAutoUpdate(options = {}, dependencies = {}) {
dryRun: Boolean(options.dryRun),
repoRoot,
results,
warnings,
summary: {
checkedCount: results.length,
updatedCount: 0,
@@ -296,6 +305,7 @@ function runAutoUpdate(options = {}, dependencies = {}) {
dryRun: Boolean(options.dryRun),
repoRoot,
results,
warnings,
summary: {
checkedCount: results.length,
updatedCount: results.filter(result => result.status === 'updated' || result.status === 'planned').length,
@@ -306,7 +316,13 @@ function runAutoUpdate(options = {}, dependencies = {}) {
function printHuman(result) {
if (result.results.length === 0) {
console.log('No ECC install-state files found for the current home/project context.');
const hasWarnings = Array.isArray(result.warnings) && result.warnings.length > 0;
console.log(hasWarnings
? 'No active ECC install-state files found for the current home/project context.'
: 'No ECC install-state files found for the current home/project context.');
for (const warning of Array.isArray(result.warnings) ? result.warnings : []) {
console.log(`Warning: ${warning}`);
}
return;
}
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env node
'use strict';
const {
beginLegacySyncState,
finalizeLegacySyncState,
recordLegacySyncPath,
rollbackLegacyCodexSync,
} = require('../lib/codex-legacy-sync');
function readFlag(args, name) {
const index = args.indexOf(name);
return index === -1 ? null : args[index + 1] || null;
}
function main(argv = process.argv.slice(2)) {
const command = argv[0];
if (command === 'begin') {
const codexHome = readFlag(argv, '--codex-home');
const backupDir = readFlag(argv, '--backup-dir');
if (!codexHome || !backupDir) throw new Error('begin requires --codex-home and --backup-dir');
process.stdout.write(`${beginLegacySyncState({
codexHome,
backupDir,
previousHooksPath: readFlag(argv, '--previous-hooks-path') || '',
installedHooksPath: readFlag(argv, '--installed-hooks-path'),
})}\n`);
return;
}
if (command === 'record') {
const statePath = readFlag(argv, '--state');
const filePath = readFlag(argv, '--path');
if (!statePath || !filePath) throw new Error('record requires --state and --path');
recordLegacySyncPath({ statePath, filePath });
return;
}
if (command === 'finalize') {
const statePath = readFlag(argv, '--state');
if (!statePath) throw new Error('finalize requires --state');
finalizeLegacySyncState({ statePath });
return;
}
if (command === 'rollback') {
const statePath = readFlag(argv, '--state');
if (!statePath) throw new Error('rollback requires --state');
const result = rollbackLegacyCodexSync({ statePath });
process.stdout.write(`${JSON.stringify(result)}\n`);
if (result.status !== 'rolled-back') process.exitCode = 1;
return;
}
throw new Error('Usage: legacy-sync-state.js <begin|record|finalize|rollback> [options]');
}
try {
main();
} catch (error) {
process.stderr.write(`[ecc-sync] ERROR: ${error.message}\n`);
process.exit(1);
}
module.exports = { main, readFlag };
+14 -3
View File
@@ -36,7 +36,7 @@ Targets:
claude (default) - Install ECC into ~/.claude/ with managed rules under rules/ecc and flat skills under skills/
claude-project - Install ECC into ./.claude/ (per-project) with managed rules under rules/ecc and flat skills under skills/
cursor - Install rules, hooks, and bundled Cursor configs to ./.cursor/
antigravity - Install rules, workflows, skills, and agents to ./.agent/
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/
@@ -134,7 +134,7 @@ function printHumanPlan(plan, dryRun) {
console.log('\nCompute: ' + getComputeSponsorCopy());
}
function main() {
async function main() {
try {
const options = parseInstallArgs(process.argv);
@@ -177,7 +177,18 @@ function main() {
return;
}
const result = applyInstallPlan(rawPlan);
let result = applyInstallPlan(rawPlan);
const { projectCanonicalInstallState } = require('./lib/install-state-store-sync');
const installStateProjection = await projectCanonicalInstallState(result.statePreview, {
homeDir: process.env.HOME || os.homedir(),
});
result = {
...result,
installStateProjection,
warnings: installStateProjection.warning
? [...result.warnings, `Install health projection warning: ${installStateProjection.warning.message}`]
: result.warnings,
};
if (options.json) {
console.log(JSON.stringify({ dryRun: false, result }, null, 2));
} else {
+479
View File
@@ -0,0 +1,479 @@
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');
const SCHEMA = 'ecc.codex-legacy-sync.v1';
const BEGIN_MARKER = '<!-- BEGIN ECC -->';
const END_MARKER = '<!-- END ECC -->';
function getStatePath(codexHome) {
return path.join(codexHome, 'ecc', 'legacy-sync-state.json');
}
function digestFile(filePath) {
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
}
function atomicWriteJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
fs.renameSync(tempPath, filePath);
}
function readState(statePath) {
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
if (state.schema !== SCHEMA || !Array.isArray(state.paths)) {
throw new Error(`Invalid legacy Codex sync state at ${statePath}`);
}
return state;
}
function hasUnsafeManagedAncestor(filePath, codexHome) {
const relativePath = path.relative(codexHome, filePath);
if (relativePath === '' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
return relativePath !== '';
}
const segments = relativePath.split(path.sep).slice(0, -1);
let currentPath = codexHome;
for (const segment of [null, ...segments]) {
if (segment !== null) currentPath = path.join(currentPath, segment);
try {
const stat = fs.lstatSync(currentPath);
if (stat.isSymbolicLink() || !stat.isDirectory()) return true;
} catch (error) {
if (error.code === 'ENOENT') break;
throw error;
}
}
return false;
}
function isWithinRoot(filePath, rootPath) {
const relativePath = path.relative(rootPath, filePath);
return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
}
function getTrustedRoot(state, filePath) {
const roots = Array.isArray(state.trustedRoots) && state.trustedRoots.length > 0
? state.trustedRoots
: [state.codexHome];
return roots
.map(rootPath => path.resolve(rootPath))
.find(rootPath => isWithinRoot(filePath, rootPath)) || null;
}
function snapshotLegacyPath(filePath) {
let previousContentBase64 = null;
let previousMode = null;
let previousType = 'missing';
try {
const stat = fs.lstatSync(filePath);
if (stat.isFile()) {
previousType = 'file';
previousContentBase64 = fs.readFileSync(filePath).toString('base64');
previousMode = stat.mode & 0o777;
} else {
previousType = stat.isSymbolicLink() ? 'symlink' : 'other';
}
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
if (previousType === 'symlink' || previousType === 'other') {
throw new Error(`Refusing to manage non-regular legacy sync path: ${filePath}`);
}
return {
path: filePath,
installedSha256: null,
previousType,
previousContentBase64,
previousMode,
};
}
function assertInstalledStateUnmodified(state) {
for (const entry of state.paths) {
const filePath = path.resolve(entry.path);
const trustedRoot = getTrustedRoot(state, filePath);
if (!trustedRoot || hasUnsafeManagedAncestor(filePath, trustedRoot)) {
throw new Error(`Refusing to reuse unsafe legacy Codex ownership path: ${filePath}`);
}
let stat = null;
try {
stat = fs.lstatSync(filePath);
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
if (!entry.installedSha256) {
if (stat) throw new Error(`Refusing to replace modified legacy Codex artifact: ${filePath}`);
continue;
}
if (!stat || !stat.isFile() || digestFile(filePath) !== entry.installedSha256) {
throw new Error(`Refusing to replace modified legacy Codex artifact: ${filePath}`);
}
}
}
function beginLegacySyncState(options) {
const codexHome = path.resolve(options.codexHome);
const statePath = getStatePath(codexHome);
const configPath = path.join(codexHome, 'config.toml');
const agentsPath = path.join(codexHome, 'AGENTS.md');
const installedHooksPath = options.installedHooksPath ? path.resolve(options.installedHooksPath) : null;
const priorState = fs.existsSync(statePath) ? readState(statePath) : null;
if (priorState && priorState.status !== 'installed') {
throw new Error(`Legacy Codex sync state requires recovery before reinstall: ${statePath}`);
}
if (priorState) assertInstalledStateUnmodified(priorState);
const trustedRoots = [...new Set([
codexHome,
...(Array.isArray(priorState?.trustedRoots) ? priorState.trustedRoots : []),
...(priorState?.installedHooksPath ? [priorState.installedHooksPath] : []),
...(installedHooksPath ? [installedHooksPath] : []),
].map(rootPath => path.resolve(rootPath)))];
const state = priorState ? {
...priorState,
status: 'applying',
updatedAt: new Date().toISOString(),
backupDir: options.backupDir ? path.resolve(options.backupDir) : priorState.backupDir,
installedHooksPath,
trustedRoots,
rollbackPreviousHooksPath: options.previousHooksPath || null,
rollbackPaths: priorState.paths.map(entry => snapshotLegacyPath(path.resolve(entry.path))),
previousInstalledState: priorState,
} : {
schema: SCHEMA,
status: 'applying',
createdAt: new Date().toISOString(),
codexHome,
backupDir: options.backupDir ? path.resolve(options.backupDir) : null,
previousHooksPath: options.previousHooksPath || null,
installedHooksPath,
trustedRoots,
before: {},
paths: [],
rollbackPaths: [],
};
for (const [key, filePath] of [['config', configPath], ['agents', agentsPath]]) {
if (priorState) break;
if (fs.existsSync(filePath)) {
const stat = fs.lstatSync(filePath);
if (!stat.isFile()) {
throw new Error(`Refusing to snapshot non-regular legacy sync path: ${filePath}`);
}
state.before[key] = fs.readFileSync(filePath, 'utf8');
} else {
state.before[key] = null;
}
}
atomicWriteJson(statePath, state);
return statePath;
}
function recordLegacySyncPath(options) {
const state = readState(options.statePath);
const filePath = path.resolve(options.filePath);
const trustedRoot = getTrustedRoot(state, filePath);
if (!trustedRoot) {
throw new Error(`Refusing to record a legacy sync path outside trusted roots: ${filePath}`);
}
if (hasUnsafeManagedAncestor(filePath, trustedRoot)) {
throw new Error(`Refusing to manage legacy sync path through symlinked ancestor: ${filePath}`);
}
if (!state.paths.some(entry => entry.path === filePath)) {
const snapshot = snapshotLegacyPath(filePath);
state.paths.push(snapshot);
state.rollbackPaths = [...(state.rollbackPaths || []), { ...snapshot }];
atomicWriteJson(options.statePath, state);
}
}
function rollbackLegacyCodexSync(options) {
const state = readState(options.statePath);
const restoredPaths = [];
const retainedPaths = [];
const rollbackPaths = Array.isArray(state.rollbackPaths) ? state.rollbackPaths : state.paths;
for (const entry of [...rollbackPaths].reverse()) {
const filePath = path.resolve(entry.path);
const trustedRoot = getTrustedRoot(state, filePath);
if (!trustedRoot) {
retainedPaths.push(filePath);
continue;
}
if (hasUnsafeManagedAncestor(filePath, trustedRoot)) {
retainedPaths.push(filePath);
continue;
}
let currentStat = null;
try {
currentStat = fs.lstatSync(filePath);
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
if (currentStat && !currentStat.isFile() && !currentStat.isSymbolicLink()) {
retainedPaths.push(filePath);
continue;
}
if (entry.previousType === 'file' && typeof entry.previousContentBase64 === 'string') {
if (currentStat && currentStat.isSymbolicLink()) {
retainedPaths.push(filePath);
continue;
}
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
fs.writeFileSync(filePath, Buffer.from(entry.previousContentBase64, 'base64'), {
mode: entry.previousMode || 0o600,
});
if (entry.previousMode) fs.chmodSync(filePath, entry.previousMode);
restoredPaths.push(filePath);
} else if (entry.previousType === 'missing' || entry.previousType === undefined) {
if (currentStat) fs.rmSync(filePath, { force: true });
restoredPaths.push(filePath);
} else {
retainedPaths.push(filePath);
}
}
if (state.installedHooksPath) {
const getHooks = options.getGlobalHooksPath || defaultGetHooksPath;
const setHooks = options.setGlobalHooksPath || defaultSetHooksPath;
const currentHooks = getHooks();
if (currentHooks && path.resolve(currentHooks) === path.resolve(state.installedHooksPath)) {
setHooks(state.rollbackPreviousHooksPath ?? state.previousHooksPath ?? '');
} else if (currentHooks && currentHooks !== (state.rollbackPreviousHooksPath ?? state.previousHooksPath)) {
retainedPaths.push(`git:core.hooksPath=${currentHooks}`);
}
}
if (retainedPaths.length === 0) {
if (state.previousInstalledState) {
atomicWriteJson(options.statePath, state.previousInstalledState);
} else {
fs.rmSync(options.statePath, { force: true });
}
}
return {
status: retainedPaths.length === 0 ? 'rolled-back' : 'partial',
statePath: options.statePath,
restoredPaths,
retainedPaths: [...new Set(retainedPaths)].sort(),
};
}
function finalizeLegacySyncState(options) {
const state = readState(options.statePath);
state.status = 'installed';
state.installedAt = new Date().toISOString();
delete state.rollbackPaths;
delete state.rollbackPreviousHooksPath;
delete state.previousInstalledState;
state.paths = state.paths.map(entry => ({
...entry,
installedSha256: getTrustedRoot(state, path.resolve(entry.path))
&& !hasUnsafeManagedAncestor(entry.path, getTrustedRoot(state, path.resolve(entry.path)))
&& fs.existsSync(entry.path)
&& fs.lstatSync(entry.path).isFile()
? digestFile(entry.path)
: null,
}));
atomicWriteJson(options.statePath, state);
return state;
}
function stripMarkerBlock(content) {
const markers = [];
let fence = null;
let offset = 0;
for (const lineWithEnding of content.match(/.*(?:\r?\n|$)/g) || []) {
if (lineWithEnding === '') continue;
const line = lineWithEnding.replace(/\r?\n$/, '');
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})(.*)$/);
if (fenceMatch) {
const run = fenceMatch[1];
const marker = run[0];
if (!fence) {
fence = { marker, length: run.length };
} else if (
marker === fence.marker
&& run.length >= fence.length
&& fenceMatch[2].trim() === ''
) {
fence = null;
}
} else if (!fence && (line === BEGIN_MARKER || line === END_MARKER)) {
markers.push({ marker: line, index: offset });
}
offset += lineWithEnding.length;
}
const begins = markers.filter(match => match.marker === BEGIN_MARKER);
const ends = markers.filter(match => match.marker === END_MARKER);
if (begins.length !== 1 || ends.length !== 1 || ends[0].index < begins[0].index) {
return content;
}
const suffixStart = ends[0].index + END_MARKER.length;
const suffixWithLineEnding = content.slice(suffixStart).replace(/^\r?\n/, '');
return `${content.slice(0, begins[0].index)}${suffixWithLineEnding}`;
}
function defaultGetHooksPath() {
try {
return execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], {
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000,
}).trim();
} catch (_error) {
return '';
}
}
function defaultSetHooksPath(value) {
const args = value
? ['config', '--global', 'core.hooksPath', value]
: ['config', '--global', '--unset-all', 'core.hooksPath'];
try {
execFileSync('git', args, { stdio: 'ignore', timeout: 5000 });
} catch (error) {
if (value || error.status !== 5) throw error;
}
}
function listLegacyCandidates(codexHome) {
const candidates = [];
const promptsDir = path.join(codexHome, 'prompts');
if (fs.existsSync(promptsDir)) {
for (const entry of fs.readdirSync(promptsDir)) {
if (entry.startsWith('ecc-') || entry.startsWith('ecc_') || entry.includes('ecc-rules-pack')) {
candidates.push(path.join(promptsDir, entry));
}
}
}
for (const relativePath of [
'docs/CODEX-NAVIGATION-GUIDE.md',
'docs/COMMAND-AGENT-MAP.md',
'COMMANDS-QUICK-REF.md',
'CONTRIBUTING.md',
'.github/PULL_REQUEST_TEMPLATE.md',
'ecc-prompts-manifest.txt',
'ecc-extension-prompts-manifest.txt',
]) {
const candidate = path.join(codexHome, relativePath);
if (fs.existsSync(candidate)) candidates.push(candidate);
}
return candidates;
}
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);
const dryRun = options.dryRun === true;
const retainedPaths = [];
const plannedRemovals = [];
const removedPaths = [];
const agentsPath = path.join(codexHome, 'AGENTS.md');
const state = fs.existsSync(statePath) ? readState(statePath) : null;
if (!state) {
if (fs.existsSync(agentsPath)) {
const agentsStat = fs.lstatSync(agentsPath);
if (!agentsStat.isFile()) {
retainedPaths.push(agentsPath);
} else {
const content = fs.readFileSync(agentsPath, 'utf8');
const stripped = stripMarkerBlock(content);
if (stripped !== content) {
plannedRemovals.push(`${agentsPath}#ecc-marker-block`);
if (!dryRun) fs.writeFileSync(agentsPath, stripped, 'utf8');
}
}
}
retainedPaths.push(...listLegacyCandidates(codexHome));
return {
status: dryRun ? 'planned' : retainedPaths.length > 0 ? 'partial' : plannedRemovals.length > 0 ? 'uninstalled' : 'not-found',
statePath: null,
plannedRemovals,
removedPaths,
retainedPaths: [...new Set(retainedPaths)].sort(),
warnings: retainedPaths.length > 0
? ['Legacy Codex artifacts without an ownership manifest were preserved for manual review.']
: [],
};
}
for (const entry of state.paths) {
const filePath = path.resolve(entry.path);
const trustedRoot = getTrustedRoot(state, filePath);
if (!trustedRoot) {
retainedPaths.push(filePath);
continue;
}
if (hasUnsafeManagedAncestor(filePath, trustedRoot)) {
retainedPaths.push(filePath);
continue;
}
if (!fs.existsSync(filePath)) continue;
const currentStat = fs.lstatSync(filePath);
const matches = entry.installedSha256 && currentStat.isFile()
? digestFile(filePath) === entry.installedSha256
: false;
if (!matches) {
retainedPaths.push(filePath);
continue;
}
plannedRemovals.push(filePath);
if (!dryRun) {
if (entry.previousType === 'file' && typeof entry.previousContentBase64 === 'string') {
fs.writeFileSync(filePath, Buffer.from(entry.previousContentBase64, 'base64'), {
mode: entry.previousMode || 0o600,
});
} else if (entry.previousType === 'missing' || entry.previousType === undefined) {
fs.rmSync(filePath, { force: true });
} else {
retainedPaths.push(filePath);
continue;
}
removedPaths.push(filePath);
}
}
if (state.installedHooksPath) {
const getHooks = options.getGlobalHooksPath || defaultGetHooksPath;
const setHooks = options.setGlobalHooksPath || defaultSetHooksPath;
const currentHooks = getHooks();
if (path.resolve(currentHooks || '.') === path.resolve(state.installedHooksPath)) {
if (!dryRun) setHooks(state.previousHooksPath || '');
} else if (currentHooks) {
retainedPaths.push(`git:core.hooksPath=${currentHooks}`);
}
}
if (!dryRun && retainedPaths.length === 0) {
fs.rmSync(statePath, { force: true });
}
return {
status: dryRun ? 'planned' : retainedPaths.length > 0 ? 'partial' : 'uninstalled',
statePath,
plannedRemovals: [...new Set(plannedRemovals)],
removedPaths,
retainedPaths: [...new Set(retainedPaths)].sort(),
warnings: retainedPaths.length > 0
? ['Modified or unverifiable legacy Codex artifacts were preserved.']
: [],
};
}
module.exports = {
BEGIN_MARKER,
END_MARKER,
SCHEMA,
beginLegacySyncState,
finalizeLegacySyncState,
getStatePath,
recordLegacySyncPath,
rollbackLegacyCodexSync,
stripMarkerBlock,
uninstallLegacyCodexSync,
};
+2 -2
View File
@@ -109,8 +109,8 @@ const HARNESS_CAPABILITIES = deepFreeze([
installMode: 'managed-project',
guidedReady: false,
availability: 'advanced',
destination: './.agent',
scopes: [scope('project', 'antigravity', './.agent')],
destination: './.agents',
scopes: [scope('project', 'antigravity', './.agents')],
hooks: hooks('not-configured', false, 'ECC hooks are not configured by this adapter.'),
aliases: ['google-antigravity'],
},
+46 -8
View File
@@ -80,7 +80,8 @@ 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']);
const IGNORED_DIRECTORY_NAMES = new Set(['node_modules', '.git', '__pycache__']);
const IGNORED_FILE_EXTENSIONS = new Set(['.pyc', '.pyo', '.pyd']);
function listFilesRecursive(dirPath) {
if (!fs.existsSync(dirPath)) {
@@ -101,6 +102,9 @@ function listFilesRecursive(dirPath) {
files.push(path.join(entry.name, childFile));
}
} else if (entry.isFile()) {
if (IGNORED_FILE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
continue;
}
files.push(entry.name);
}
}
@@ -128,7 +132,14 @@ function previewInstallPlan(plan) {
return previewPlan(plan);
}
function buildCopyFileOperation({ moduleId, sourcePath, sourceRelativePath, destinationPath, strategy }) {
function buildCopyFileOperation({
moduleId,
sourcePath,
sourceRelativePath,
destinationPath,
strategy,
contentTransform,
}) {
return {
kind: 'copy-file',
moduleId,
@@ -137,7 +148,8 @@ function buildCopyFileOperation({ moduleId, sourcePath, sourceRelativePath, dest
destinationPath,
strategy,
ownership: 'managed',
scaffoldOnly: false
scaffoldOnly: false,
...(contentTransform ? { contentTransform } : {}),
};
}
@@ -163,7 +175,8 @@ function addRecursiveCopyOperations(operations, options) {
sourcePath,
sourceRelativePath,
destinationPath,
strategy: options.strategy || 'preserve-relative-path'
strategy: options.strategy || 'preserve-relative-path',
contentTransform: options.contentTransform,
})
);
}
@@ -343,6 +356,7 @@ function planClaudeStyleLegacyInstall(context, { adapterId, adapterRootInput, ru
return {
mode: 'legacy',
sourceRoot: context.sourceRoot,
adapter,
target: adapterId,
targetRoot,
@@ -514,7 +528,8 @@ function planAntigravityLegacyInstall(context) {
moduleId: 'legacy-antigravity-install',
sourceRoot: context.sourceRoot,
sourceRelativeDir: 'agents',
destinationDir: path.join(targetRoot, 'skills')
destinationDir: path.join(targetRoot, 'agents'),
contentTransform: 'antigravity-agent-frontmatter'
});
addRecursiveCopyOperations(operations, {
moduleId: 'legacy-antigravity-install',
@@ -589,6 +604,7 @@ function createLegacyInstallPlan(options = {}) {
return {
mode: 'legacy',
sourceRoot,
target: plan.target,
adapter: {
id: plan.adapter.id,
@@ -630,6 +646,7 @@ function createLegacyCompatInstallPlan(options = {}) {
includeComponentIds,
excludeComponentIds,
legacyLanguages: selection.legacyLanguages,
ruleLanguages: selection.ruleLanguages,
legacyMode: true,
requestProfileId: null,
requestModuleIds: [],
@@ -672,7 +689,8 @@ function materializeScaffoldOperation(sourceRoot, operation) {
sourcePath,
sourceRelativePath: operation.sourceRelativePath,
destinationPath: operation.destinationPath,
strategy: operation.strategy
strategy: operation.strategy,
contentTransform: operation.contentTransform,
})
];
}
@@ -688,11 +706,22 @@ function materializeScaffoldOperation(sourceRoot, operation) {
sourcePath: path.join(sourcePath, relativeFile),
sourceRelativePath,
destinationPath: path.join(operation.destinationPath, relativeFile),
strategy: operation.strategy
strategy: operation.strategy,
contentTransform: operation.contentTransform,
});
});
}
function isSelectedAntigravityLegacyRule(operation, ruleLanguages) {
const normalizedSourcePath = String(operation.sourceRelativePath || '').replace(/\\/g, '/');
if (!normalizedSourcePath.startsWith('rules/')) {
return true;
}
const namespace = normalizedSourcePath.split('/')[1];
return namespace === 'common' || ruleLanguages.includes(namespace);
}
function dedupeCopyFileOperations(operations) {
// A `copy-file` operation fully overwrites its destination, so when several
// of them target the same path (e.g. a generic `commands/<name>.md` shadowed
@@ -748,8 +777,16 @@ function createManifestInstallPlan(options = {}) {
exemptValidationCodes: options.exemptValidationCodes || [],
});
const adapter = getInstallTargetAdapter(target);
const materializedOperations = plan.operations.flatMap(operation => (
materializeScaffoldOperation(sourceRoot, operation)
));
const ruleLanguages = Array.isArray(options.ruleLanguages) ? [...options.ruleLanguages] : [];
const operations = dedupeCopyFileOperations(
plan.operations.flatMap(operation => materializeScaffoldOperation(sourceRoot, operation))
options.legacyMode && target === 'antigravity'
? materializedOperations.filter(operation => (
isSelectedAntigravityLegacyRule(operation, ruleLanguages)
))
: materializedOperations
);
const source = {
repoVersion: getPackageVersion(sourceRoot),
@@ -778,6 +815,7 @@ function createManifestInstallPlan(options = {}) {
return {
mode: options.mode || 'manifest',
sourceRoot,
target,
adapter: {
id: adapter.id,
+249 -20
View File
@@ -1,3 +1,4 @@
const crypto = require('crypto');
const fs = require('fs');
const { execFileSync } = require('child_process');
const os = require('os');
@@ -10,6 +11,12 @@ const { createManifestInstallPlan } = require('./install-executor');
const {
prepareClaudeSkillMigration,
} = require('./install/claude-skill-migration');
const {
getLegacyAntigravityLocation,
inspectLegacyAntigravityState,
} = require('./install/antigravity-legacy-migration');
const { adaptAntigravityAgent } = require('./install/antigravity-agent');
const { buildInstallIndex, rewriteRelativeLinks } = require('./install/link-rewrite');
const { getInstallTargetAdapter, listInstallTargetAdapters } = require('./install-targets/registry');
const OPENCODE_BUILD_ARTIFACT = path.join('.opencode', 'dist');
const OPENCODE_BUILD_SCRIPT = path.join('scripts', 'build-opencode.js');
@@ -138,6 +145,59 @@ function areFilesEqual(leftPath, rightPath) {
}
}
function hasRecordedContentDigest(operation) {
return /^[a-f0-9]{64}$/i.test(String(operation && operation.contentSha256 || ''));
}
function fileMatchesRecordedContent(filePath, operation) {
if (!hasRecordedContentDigest(operation)) {
return false;
}
try {
return crypto.createHash('sha256')
.update(readFileNoFollow(filePath))
.digest('hex') === operation.contentSha256.toLowerCase();
} catch (_error) {
return false;
}
}
function isMarkdownPath(filePath) {
return /\.(md|mdx|markdown)$/i.test(String(filePath || ''));
}
function buildLinkIndexForOperations(operations, trustedRoot) {
const mappings = (operations || [])
.filter(operation => operation.kind === 'copy-file' && operation.sourceRelativePath)
.map(operation => ({
sourceRel: operation.sourceRelativePath,
destRel: path.relative(trustedRoot, operation.destinationPath),
}));
return buildInstallIndex(mappings);
}
function transformCopyFileContent(operation, content) {
if (!operation.contentTransform) {
return content;
}
if (operation.contentTransform === 'antigravity-agent-frontmatter') {
return adaptAntigravityAgent(content, operation.sourceRelativePath);
}
throw new Error(`Unknown install content transform: ${operation.contentTransform}`);
}
function getExpectedCopyFileContent(operation, content, linkIndex) {
const transformed = transformCopyFileContent(operation, content);
if (!linkIndex || !operation.sourceRelativePath || !isMarkdownPath(operation.destinationPath)) {
return transformed;
}
return rewriteRelativeLinks(transformed, {
sourceRel: operation.sourceRelativePath,
index: linkIndex,
});
}
function isPlainObject(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
@@ -591,7 +651,7 @@ function shouldRepairFromRecordedOperations(state) {
return getManagedOperations(state).some(operation => operation.kind !== 'copy-file');
}
function executeRepairOperation(repoRoot, operation, trustedRoot) {
function executeRepairOperation(repoRoot, operation, trustedRoot, linkIndex = null) {
// Install-state is attacker-controllable; never write/delete outside the
// adapter-derived trusted root, regardless of what the state file claims
// (GHSA-hfpv-w6mp-5g95).
@@ -601,7 +661,18 @@ function executeRepairOperation(repoRoot, operation, trustedRoot) {
throw new Error(`Missing source file for repair: ${sourcePath || operation.sourceRelativePath}`);
}
if (operation.contentTransform || isMarkdownPath(operation.destinationPath)) {
const source = readFileWithMetadataNoFollow(sourcePath, 'utf8');
writeContainedFile(
operation.destinationPath,
getExpectedCopyFileContent(operation, source.content, linkIndex),
trustedRoot,
'repair',
source.mode & 0o777
);
} else {
copyContainedFile(sourcePath, operation.destinationPath, trustedRoot, 'repair');
}
return operation.destinationPath;
}
@@ -646,9 +717,35 @@ function executeRepairOperation(repoRoot, operation, trustedRoot) {
throw new Error(`Unsupported repair operation kind: ${operation.kind}`);
}
function executeUninstallOperation(operation, trustedRoot) {
function executeUninstallOperation(operation, trustedRoot, options = {}) {
// Confine deletes to the trusted install root (GHSA-hfpv-w6mp-5g95).
if (operation.kind === 'copy-file') {
if (options.preserveDriftedCopies) {
const existingDestination = getContainedExistingPath(
operation.destinationPath,
trustedRoot,
'uninstall'
);
if (!existingDestination) {
return {
removedPaths: [],
cleanupTargets: []
};
}
const recordedDigest = operation.contentSha256;
const currentDigest = /^[a-f0-9]{64}$/i.test(recordedDigest || '')
? crypto.createHash('sha256')
.update(readFileNoFollow(existingDestination))
.digest('hex')
: null;
if (!currentDigest || currentDigest !== recordedDigest.toLowerCase()) {
return {
removedPaths: [],
cleanupTargets: []
};
}
}
const removedPath = removeContainedPath(
operation.destinationPath,
trustedRoot,
@@ -794,7 +891,7 @@ function executeUninstallOperation(operation, trustedRoot) {
throw new Error(`Unsupported uninstall operation kind: ${operation.kind}`);
}
function inspectManagedOperation(repoRoot, trustedRoot, operation) {
function inspectManagedOperation(repoRoot, trustedRoot, operation, linkIndex = null) {
const destinationPath = operation.destinationPath;
if (!destinationPath) {
return {
@@ -871,7 +968,27 @@ function inspectManagedOperation(repoRoot, trustedRoot, operation) {
};
}
if (!areFilesEqual(copySourcePath, inspectedPath)) {
let contentMatches;
try {
contentMatches = hasRecordedContentDigest(operation)
? fileMatchesRecordedContent(inspectedPath, operation)
: operation.contentTransform || isMarkdownPath(operation.destinationPath)
? readFileNoFollow(inspectedPath, 'utf8') === getExpectedCopyFileContent(
operation,
readFileNoFollow(copySourcePath, 'utf8'),
linkIndex
)
: areFilesEqual(copySourcePath, inspectedPath);
} catch (_error) {
return {
status: 'unverified',
operation,
destinationPath,
sourcePath: copySourcePath
};
}
if (!contentMatches) {
return {
status: 'drifted',
operation,
@@ -963,9 +1080,10 @@ function inspectManagedOperation(repoRoot, trustedRoot, operation) {
}
function summarizeManagedOperationHealth(repoRoot, trustedRoot, operations) {
const linkIndex = buildLinkIndexForOperations(operations, trustedRoot);
return operations.reduce(
(summary, operation) => {
const inspection = inspectManagedOperation(repoRoot, trustedRoot, operation);
const inspection = inspectManagedOperation(repoRoot, trustedRoot, operation, linkIndex);
if (inspection.status === 'missing') {
summary.missing.push(inspection);
} else if (inspection.status === 'drifted') {
@@ -1023,14 +1141,18 @@ function getUnsafeOperationResult(record, operationHealth) {
};
}
function buildDiscoveryRecord(adapter, context) {
function buildDiscoveryRecord(adapter, context, location = null, knownState = null) {
const installTargetInput = {
homeDir: context.homeDir,
projectRoot: context.projectRoot,
repoRoot: context.projectRoot
};
const targetRoot = adapter.resolveRoot(installTargetInput);
const installStatePath = adapter.getInstallStatePath(installTargetInput);
const targetRoot = location
? location.targetRoot
: adapter.resolveRoot(installTargetInput);
const installStatePath = location
? location.installStatePath
: adapter.getInstallStatePath(installTargetInput);
const exists = fs.existsSync(installStatePath);
if (!exists) {
@@ -1044,7 +1166,24 @@ function buildDiscoveryRecord(adapter, context) {
installStatePath,
exists: false,
state: null,
error: null
error: null,
legacy: Boolean(location)
};
}
if (knownState) {
return {
adapter: {
id: adapter.id,
target: adapter.target,
kind: adapter.kind
},
targetRoot,
installStatePath,
exists: true,
state: knownState,
error: null,
legacy: Boolean(location)
};
}
@@ -1060,7 +1199,8 @@ function buildDiscoveryRecord(adapter, context) {
installStatePath,
exists: true,
state,
error: null
error: null,
legacy: Boolean(location)
};
} catch (error) {
return {
@@ -1073,7 +1213,8 @@ function buildDiscoveryRecord(adapter, context) {
installStatePath,
exists: true,
state: null,
error: error.message
error: error.message,
legacy: Boolean(location)
};
}
}
@@ -1085,9 +1226,43 @@ function discoverInstalledStates(options = {}) {
};
const targets = normalizeTargets(options.targets);
return targets.map(target => {
return targets.flatMap(target => {
const adapter = getInstallTargetAdapter(target);
return buildDiscoveryRecord(adapter, context);
const canonicalRecord = buildDiscoveryRecord(adapter, context);
if (adapter.target !== 'antigravity') {
return [canonicalRecord];
}
const legacyLocation = getLegacyAntigravityLocation(context.projectRoot);
const legacyInspection = inspectLegacyAntigravityState(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,
}];
}
return [
canonicalRecord,
buildDiscoveryRecord(adapter, context, legacyLocation, legacyInspection.state),
];
});
}
@@ -1115,6 +1290,14 @@ function determineStatus(issues) {
function analyzeRecord(record, context) {
const issues = [];
if (record.legacy) {
issues.push(buildIssue(
'warning',
'legacy-antigravity-layout',
'Legacy Antigravity install-state remains under .agent. Review and move any preserved modified or unmanaged files out of .agent, then rerun the Antigravity install to finish migration.'
));
}
if (record.error) {
issues.push(buildIssue('error', 'invalid-install-state', record.error));
return {
@@ -1377,10 +1560,27 @@ function assertValidInstallStateForWrite(state, label) {
function writeRefreshedInstallState(record, statePreview) {
const trustedStatePreview = buildAdapterDerivedStatePreview(statePreview, record);
assertValidInstallStateForWrite(trustedStatePreview, record.installStatePath);
const stateWithCurrentDigests = {
...trustedStatePreview,
operations: (trustedStatePreview.operations || []).map(operation => {
if (!operation.destinationPath) {
return { ...operation };
}
try {
const contentSha256 = crypto.createHash('sha256')
.update(readFileNoFollow(operation.destinationPath))
.digest('hex');
return { ...operation, contentSha256 };
} catch (_error) {
const { contentSha256: _staleDigest, ...operationWithoutDigest } = operation;
return operationWithoutDigest;
}
}),
};
assertValidInstallStateForWrite(stateWithCurrentDigests, record.installStatePath);
return writeContainedFile(
record.installStatePath,
formatJson(trustedStatePreview),
formatJson(stateWithCurrentDigests),
record.targetRoot,
'repair'
);
@@ -1427,7 +1627,7 @@ function repairInstalledStates(options = {}) {
homeDir: context.homeDir,
projectRoot: context.projectRoot,
targets: options.targets
}).filter(record => record.exists);
}).filter(record => record.exists && !record.legacy);
const results = records.map(record => {
if (record.error) {
@@ -1525,6 +1725,7 @@ function repairInstalledStates(options = {}) {
}
const repairOperations = [...operationHealth.missing.map(entry => ({ ...entry.operation })), ...operationHealth.drifted.map(entry => ({ ...entry.operation }))];
const repairLinkIndex = buildLinkIndexForOperations(desiredPlan.operations, record.targetRoot);
const legacyMigrationPaths = migration.legacyOperationsToRemove.map(
operation => operation.destinationPath
);
@@ -1557,7 +1758,8 @@ function repairInstalledStates(options = {}) {
const repairedPath = executeRepairOperation(
context.repoRoot,
operation,
record.targetRoot
record.targetRoot,
repairLinkIndex
);
if (repairedPath) {
repairedPaths.push(repairedPath);
@@ -1576,7 +1778,17 @@ function repairInstalledStates(options = {}) {
}
}
}
writeRefreshedInstallState(record, desiredPlan.statePreview);
const changedInstalledBytes = repairOperations.length > 0
|| needsOpencodeBuild
|| hasLegacyMigration;
const statePreviewToWrite = changedInstalledBytes
? desiredPlan.statePreview
: {
...desiredPlan.statePreview,
installedAt: record.state.installedAt,
source: { ...record.state.source },
};
writeRefreshedInstallState(record, statePreviewToWrite);
return {
adapter: record.adapter,
@@ -1682,8 +1894,21 @@ function uninstallInstalledStates(options = {}) {
}
const state = record.state;
const managedOperations = getManagedOperations(state);
if (record.legacy && managedOperations.length > 0) {
return {
adapter: record.adapter,
status: 'partial',
installStatePath: record.installStatePath,
removedPaths: [],
plannedRemovals: [],
retainedPaths: managedOperations.map(operation => operation.destinationPath),
warning: 'Legacy Antigravity files were preserved because their provenance cannot be revalidated during uninstall. Rerun the Antigravity installer to migrate verified files, then review .agent manually.',
error: null
};
}
const plannedRemovals = Array.from(new Set([
...getManagedOperations(state).map(operation => operation.destinationPath),
...managedOperations.map(operation => operation.destinationPath),
record.installStatePath
]));
@@ -1704,7 +1929,9 @@ function uninstallInstalledStates(options = {}) {
const operations = getManagedOperations(state);
for (const operation of operations) {
const outcome = executeUninstallOperation(operation, record.targetRoot);
const outcome = executeUninstallOperation(operation, record.targetRoot, {
preserveDriftedCopies: record.legacy,
});
removedPaths.push(...outcome.removedPaths);
cleanupTargets.push(...outcome.cleanupTargets);
}
@@ -1749,12 +1976,14 @@ function uninstallInstalledStates(options = {}) {
checkedCount: accumulator.checkedCount + 1,
uninstalledCount: accumulator.uninstalledCount + (result.status === 'uninstalled' ? 1 : 0),
plannedRemovalCount: accumulator.plannedRemovalCount + (result.status === 'planned' ? 1 : 0),
partialCount: accumulator.partialCount + (result.status === 'partial' ? 1 : 0),
errorCount: accumulator.errorCount + (result.status === 'error' ? 1 : 0)
}),
{
checkedCount: 0,
uninstalledCount: 0,
plannedRemovalCount: 0,
partialCount: 0,
errorCount: 0
}
);
+14
View File
@@ -65,6 +65,8 @@ const LEGACY_COMPAT_BASE_MODULE_IDS_BY_TARGET = Object.freeze({
'rules-core',
'agents-core',
'commands-core',
'skill-unified-memory',
'workflow-quality',
],
zed: [
'rules-core',
@@ -132,6 +134,14 @@ const LEGACY_LANGUAGE_EXTRA_MODULE_IDS = Object.freeze({
swift: [],
typescript: ['framework-language'],
});
const LEGACY_LANGUAGE_RULE_NAMESPACES = Object.freeze({
c: 'cpp',
harmonyos: 'arkts',
javascript: 'typescript',
go: 'golang',
golang: 'golang',
rails: 'ruby',
});
const TARGET_DEFAULT_PROFILE_IDS = Object.freeze({
opencode: 'opencode',
});
@@ -500,6 +510,9 @@ function resolveLegacyCompatibilitySelection(options = {}) {
const canonicalLegacyLanguages = normalizedLegacyLanguages
.map(language => LEGACY_LANGUAGE_ALIAS_TO_CANONICAL[language]);
const ruleLanguages = normalizedLegacyLanguages.map(language => (
LEGACY_LANGUAGE_RULE_NAMESPACES[language] || language
));
const baseModuleIds = LEGACY_COMPAT_BASE_MODULE_IDS_BY_TARGET[target || 'claude']
|| LEGACY_COMPAT_BASE_MODULE_IDS_BY_TARGET.claude;
const moduleIds = dedupeStrings([
@@ -514,6 +527,7 @@ function resolveLegacyCompatibilitySelection(options = {}) {
return {
legacyLanguages: normalizedLegacyLanguages,
canonicalLegacyLanguages,
ruleLanguages,
moduleIds,
};
}
+63
View File
@@ -0,0 +1,63 @@
'use strict';
const {
createStateStore,
projectInstallState,
reconcileCurrentInstallState,
} = require('./state-store');
function openFailure(error) {
return {
status: 'warning',
warningCount: 1,
warnings: [{
code: 'projection-open-failed',
message: error.message,
}],
warning: {
code: 'projection-open-failed',
message: error.message,
},
};
}
async function withStateStore(options, operation) {
const openStore = options.createStore || createStateStore;
let store;
try {
store = await openStore({
dbPath: options.dbPath,
homeDir: options.homeDir,
});
return operation(store);
} catch (error) {
return openFailure(error);
} finally {
if (store) {
try {
store.close();
} catch (_error) {
// Projection is a derived cache. A close failure must not invalidate
// the canonical JSON install-state or a completed file operation.
}
}
}
}
async function projectCanonicalInstallState(state, options = {}) {
return withStateStore(options, store => projectInstallState(store, state));
}
async function reconcileCanonicalInstallStates(options = {}) {
return withStateStore(options, store => reconcileCurrentInstallState(store, {
homeDir: options.homeDir,
projectRoot: options.projectRoot,
targets: options.targets,
discoverInstalledStates: options.discoverInstalledStates,
}));
}
module.exports = {
projectCanonicalInstallState,
reconcileCanonicalInstallStates,
};
@@ -3,11 +3,12 @@ const path = require('path');
const {
createFlatRuleOperations,
createInstallTargetAdapter,
createManagedOperation,
createManagedScaffoldOperation,
normalizeRelativePath,
} = require('./helpers');
const SUPPORTED_SOURCE_PREFIXES = ['rules', 'commands', 'agents', '.agents', 'AGENTS.md'];
const SUPPORTED_SOURCE_PREFIXES = ['rules', 'commands', 'agents', 'skills'];
function supportsAntigravitySourcePath(sourceRelativePath) {
const normalizedPath = normalizeRelativePath(sourceRelativePath);
@@ -20,7 +21,7 @@ module.exports = createInstallTargetAdapter({
id: 'antigravity-project',
target: 'antigravity',
kind: 'project',
rootSegments: ['.agent'],
rootSegments: ['.agents'],
installStatePathSegments: ['ecc-install-state.json'],
supportsModule(module) {
const paths = Array.isArray(module && module.paths) ? module.paths : [];
@@ -47,38 +48,70 @@ module.exports = createInstallTargetAdapter({
return paths
.filter(supportsAntigravitySourcePath)
.flatMap(sourceRelativePath => {
if (sourceRelativePath === 'rules') {
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
if (normalizedSourcePath === 'rules') {
return createFlatRuleOperations({
moduleId: module.id,
repoRoot,
sourceRelativePath,
sourceRelativePath: normalizedSourcePath,
destinationDir: path.join(targetRoot, 'rules'),
});
}
if (sourceRelativePath === 'commands') {
if (
normalizedSourcePath === 'commands'
|| normalizedSourcePath.startsWith('commands/')
) {
const commandRelativePath = normalizedSourcePath === 'commands'
? ''
: normalizedSourcePath.slice('commands/'.length);
return [
createManagedScaffoldOperation(
module.id,
sourceRelativePath,
path.join(targetRoot, 'workflows'),
normalizedSourcePath,
path.join(targetRoot, 'workflows', commandRelativePath),
'preserve-relative-path'
),
];
}
if (sourceRelativePath === 'agents') {
if (
normalizedSourcePath === 'agents'
|| normalizedSourcePath.startsWith('agents/')
) {
const agentRelativePath = normalizedSourcePath === 'agents'
? ''
: normalizedSourcePath.slice('agents/'.length);
return [
createManagedOperation({
moduleId: module.id,
sourceRelativePath: normalizedSourcePath,
destinationPath: path.join(targetRoot, 'agents', agentRelativePath),
strategy: 'preserve-relative-path',
contentTransform: 'antigravity-agent-frontmatter',
}),
];
}
if (
normalizedSourcePath === 'skills'
|| normalizedSourcePath.startsWith('skills/')
) {
const skillRelativePath = normalizedSourcePath === 'skills'
? ''
: normalizedSourcePath.slice('skills/'.length);
return [
createManagedScaffoldOperation(
module.id,
sourceRelativePath,
path.join(targetRoot, 'skills'),
normalizedSourcePath,
path.join(targetRoot, 'skills', skillRelativePath),
'preserve-relative-path'
),
];
}
return [adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput)];
return [];
});
});
},
+61
View File
@@ -0,0 +1,61 @@
'use strict';
const TOOL_NAMES = Object.freeze({
Read: 'view_file',
Write: 'write_to_file',
Edit: 'replace_file_content',
Grep: 'grep_search',
Glob: 'find_by_name',
Bash: 'run_command',
WebSearch: 'search_web',
WebFetch: 'read_url_content',
});
const MODEL_NAMES = Object.freeze({
haiku: 'flash',
sonnet: 'pro',
opus: 'pro',
});
function splitFrontmatter(source, label) {
const match = String(source || '').match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
if (!match) {
throw new Error(`Cannot adapt Antigravity agent ${label}: missing YAML frontmatter`);
}
const frontmatter = require('js-yaml').load(match[1]);
if (!frontmatter || typeof frontmatter !== 'object' || Array.isArray(frontmatter)) {
throw new Error(`Cannot adapt Antigravity agent ${label}: frontmatter must be an object`);
}
return {
frontmatter,
body: source.slice(match[0].length),
};
}
function normalizeToolNames(value) {
const names = Array.isArray(value) ? value : String(value || '').split(',');
return [...new Set(names
.map(name => String(name).trim())
.filter(name => Object.hasOwn(TOOL_NAMES, name))
.map(name => TOOL_NAMES[name]))];
}
function adaptAntigravityAgent(source, label = '<unknown>') {
const { frontmatter, body } = splitFrontmatter(source, label);
const { color: _claudeColor, ...supportedFrontmatter } = frontmatter;
const adapted = {
...supportedFrontmatter,
tools: normalizeToolNames(frontmatter.tools),
model: MODEL_NAMES[frontmatter.model] || frontmatter.model,
};
const serialized = require('js-yaml')
.dump(adapted, { lineWidth: -1, noRefs: true })
.trimEnd();
return `---\n${serialized}\n---\n${body}`;
}
module.exports = {
adaptAntigravityAgent,
};
@@ -0,0 +1,413 @@
'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 ANTIGRAVITY_TARGET = 'antigravity';
const CANONICAL_ROOT_NAME = '.agents';
const LEGACY_ROOT_NAME = '.agent';
const INSTALL_STATE_NAME = 'ecc-install-state.json';
function samePath(leftPath, rightPath) {
const left = path.resolve(leftPath);
const right = path.resolve(rightPath);
if (process.platform === 'win32') {
return left.toLowerCase() === right.toLowerCase();
}
return 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 getLegacyAntigravityLocation(projectRoot) {
const targetRoot = path.join(path.resolve(projectRoot), LEGACY_ROOT_NAME);
return {
targetRoot,
installStatePath: path.join(targetRoot, INSTALL_STATE_NAME),
};
}
function getLegacyLocationForPlan(plan) {
if (
!plan
|| !plan.adapter
|| plan.adapter.target !== ANTIGRAVITY_TARGET
|| typeof plan.targetRoot !== 'string'
|| path.basename(path.resolve(plan.targetRoot)) !== CANONICAL_ROOT_NAME
) {
return null;
}
return getLegacyAntigravityLocation(path.dirname(path.resolve(plan.targetRoot)));
}
function inspectLegacyAntigravityState(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 isAntigravity = state.target.target === ANTIGRAVITY_TARGET
|| state.target.id === 'antigravity-project';
if (
!isAntigravity
|| !samePath(state.target.root, location.targetRoot)
|| !samePath(state.target.installStatePath, location.installStatePath)
|| state.operations.some(operation => (
operation.kind !== 'copy-file'
|| operation.ownership !== 'managed'
))
) {
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 Antigravity install-state at ${location.installStatePath}: ${error.message}`,
};
}
}
function readValidLegacyAntigravityState(location) {
const inspection = inspectLegacyAntigravityState(location);
return inspection.status === 'valid' ? inspection.state : null;
}
function sha256FileNoFollow(filePath) {
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
const descriptor = fs.openSync(filePath, flags);
try {
const stat = fs.fstatSync(descriptor);
if (!stat.isFile()) {
return null;
}
return {
digest: crypto.createHash('sha256').update(fs.readFileSync(descriptor)).digest('hex'),
stat,
};
} finally {
fs.closeSync(descriptor);
}
}
function getExpectedLegacyDestination(operation, legacyRoot) {
const sourceRelativePath = String(operation.sourceRelativePath || '')
.replace(/\\/g, '/')
.replace(/^\.\//, '');
const parts = sourceRelativePath.split('/');
if (
path.posix.isAbsolute(sourceRelativePath)
|| path.win32.isAbsolute(sourceRelativePath)
|| parts.some(part => part === '' || part === '.' || part === '..')
) {
return null;
}
if (parts[0] === 'rules' && parts.length >= 2) {
const fileName = parts.length === 2
? parts[1]
: `${parts[1]}-${parts.slice(2).join('-')}`;
return path.join(legacyRoot, 'rules', fileName);
}
if (parts[0] === 'commands' && parts.length >= 2) {
return path.join(legacyRoot, 'workflows', ...parts.slice(1));
}
if (parts[0] === 'agents' && parts.length >= 2) {
return path.join(legacyRoot, 'skills', ...parts.slice(1));
}
if (parts[0] === '.agents' && parts.length >= 2) {
return path.join(legacyRoot, '.agents', ...parts.slice(1));
}
if (sourceRelativePath === 'AGENTS.md') {
return path.join(legacyRoot, 'AGENTS.md');
}
return null;
}
function getVerifiedManagedFile(operation, legacyRoot, sourceRoot) {
if (
!operation
|| operation.ownership !== 'managed'
|| operation.kind !== 'copy-file'
|| typeof operation.destinationPath !== 'string'
|| typeof sourceRoot !== 'string'
|| !/^[a-f0-9]{64}$/i.test(operation.contentSha256 || '')
) {
return null;
}
let destinationPath;
let sourcePath;
try {
const expectedDestination = getExpectedLegacyDestination(operation, legacyRoot);
if (!expectedDestination || !samePath(operation.destinationPath, expectedDestination)) {
return null;
}
destinationPath = assertWithinTrustedRoot(
operation.destinationPath,
legacyRoot,
'migrate legacy Antigravity install'
);
sourcePath = assertWithinTrustedRoot(
path.join(sourceRoot, operation.sourceRelativePath),
sourceRoot,
'verify legacy Antigravity source'
);
} catch (_error) {
return null;
}
if (!pathExists(destinationPath)) {
return { destinationPath, missing: true };
}
const stat = fs.lstatSync(destinationPath);
if (!stat.isFile() || stat.isSymbolicLink()) {
return null;
}
const destination = sha256FileNoFollow(destinationPath);
if (
!destination
|| destination.digest !== operation.contentSha256.toLowerCase()
) {
return null;
}
if (!pathExists(sourcePath)) {
return {
destinationPath,
fileStat: destination.stat,
missing: false,
retainedReason: 'The current ECC source file is unavailable, so its provenance cannot be revalidated.',
};
}
const source = sha256FileNoFollow(sourcePath);
if (!source || destination.digest !== source.digest) {
return {
destinationPath,
fileStat: destination.stat,
missing: false,
retainedReason: 'The current ECC source differs from the recorded installed content, so the legacy file was preserved.',
};
}
return { destinationPath, fileStat: destination.stat, missing: false };
}
function cleanupResult(overrides = {}) {
return {
detected: false,
complete: false,
removedPaths: [],
retainedPaths: [],
warnings: [],
...overrides,
};
}
function removeEmptyParents(startPath, legacyRoot) {
let currentPath = path.dirname(startPath);
while (!samePath(currentPath, legacyRoot)) {
let safePath;
try {
safePath = assertWithinTrustedRoot(
currentPath,
legacyRoot,
'clean legacy Antigravity install'
);
} catch (_error) {
return;
}
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 listLegacyContent(legacyRoot, installStatePath) {
if (!pathExists(legacyRoot)) {
return [];
}
const content = [];
const pending = [legacyRoot];
while (pending.length > 0) {
const currentPath = pending.pop();
for (const entry of fs.readdirSync(currentPath, { withFileTypes: true })) {
const entryPath = path.join(currentPath, entry.name);
if (samePath(entryPath, installStatePath)) {
continue;
}
if (entry.isDirectory() && !entry.isSymbolicLink()) {
pending.push(entryPath);
} else {
content.push(entryPath);
}
}
}
return content;
}
function removeLegacyStateWhenEmpty(location) {
if (listLegacyContent(location.targetRoot, location.installStatePath).length > 0) {
return false;
}
try {
fs.rmSync(location.installStatePath, { force: true });
} catch (_error) {
return false;
}
try {
if (pathExists(location.targetRoot) && fs.readdirSync(location.targetRoot).length === 0) {
fs.rmdirSync(location.targetRoot);
}
} catch (_error) {
// Root cleanup is best effort after the legacy state is gone.
}
return true;
}
function cleanupLegacyAntigravityInstall(plan) {
const location = getLegacyLocationForPlan(plan);
if (!location || typeof plan.sourceRoot !== 'string' || !pathExists(plan.installStatePath)) {
return cleanupResult();
}
try {
const canonicalState = readInstallState(plan.installStatePath);
const isCanonicalState = (
canonicalState.target.target === ANTIGRAVITY_TARGET
|| canonicalState.target.id === 'antigravity-project'
)
&& samePath(canonicalState.target.root, plan.targetRoot)
&& samePath(canonicalState.target.installStatePath, plan.installStatePath);
if (!isCanonicalState) {
return cleanupResult();
}
} catch (_error) {
return cleanupResult();
}
const legacyInspection = inspectLegacyAntigravityState(location);
if (legacyInspection.status === 'unreadable') {
return cleanupResult({
detected: true,
retainedPaths: [location.targetRoot],
warnings: [legacyInspection.error],
});
}
if (legacyInspection.status !== 'valid') {
return cleanupResult();
}
const legacyState = legacyInspection.state;
const removedPaths = [];
const filesToRemove = [];
const warnings = [];
for (const operation of legacyState.operations || []) {
const verified = getVerifiedManagedFile(operation, location.targetRoot, plan.sourceRoot);
if (!verified) {
continue;
}
if (verified.missing) {
continue;
}
if (verified.retainedReason) {
warnings.push(`${verified.destinationPath}: ${verified.retainedReason}`);
continue;
}
filesToRemove.push({
destinationPath: verified.destinationPath,
fileStat: verified.fileStat,
});
}
for (const { destinationPath, fileStat } of filesToRemove) {
try {
const safeDestination = assertWithinTrustedRoot(
destinationPath,
location.targetRoot,
'remove verified legacy Antigravity file'
);
const currentStat = fs.lstatSync(safeDestination);
if (
currentStat.isSymbolicLink()
|| !currentStat.isFile()
|| currentStat.dev !== fileStat.dev
|| currentStat.ino !== fileStat.ino
) {
continue;
}
fs.rmSync(safeDestination);
removedPaths.push(safeDestination);
removeEmptyParents(safeDestination, location.targetRoot);
} catch (_error) {
// Keep failed deletions tracked in legacy state so a later install can retry.
}
}
let complete = false;
try {
complete = removeLegacyStateWhenEmpty(location);
} catch (_error) {
complete = false;
}
if (complete) {
removedPaths.push(location.installStatePath);
}
let retainedPaths = [];
if (!complete) {
try {
retainedPaths = listLegacyContent(location.targetRoot, location.installStatePath);
} catch (_error) {
retainedPaths = [location.targetRoot];
}
}
return cleanupResult({ detected: true, complete, removedPaths, retainedPaths, warnings });
}
module.exports = {
cleanupLegacyAntigravityInstall,
getLegacyAntigravityLocation,
inspectLegacyAntigravityState,
readValidLegacyAntigravityState,
};
+21 -1
View File
@@ -16,12 +16,24 @@ const {
prepareClaudeSkillMigration,
removeLegacyClaudeSkillFiles,
} = require('./claude-skill-migration');
const { cleanupLegacyAntigravityInstall } = require('./antigravity-legacy-migration');
const { buildInstallIndex, rewriteRelativeLinks } = require('./link-rewrite');
const { adaptAntigravityAgent } = require('./antigravity-agent');
function isMarkdownPath(filePath) {
return /\.(md|mdx|markdown)$/i.test(String(filePath || ''));
}
function transformInstallContent(operation, content) {
if (!operation.contentTransform) {
return content;
}
if (operation.contentTransform === 'antigravity-agent-frontmatter') {
return adaptAntigravityAgent(content, operation.sourceRelativePath);
}
throw new Error(`Unknown install content transform: ${operation.contentTransform}`);
}
// Map every copy-file operation to { sourceRel, destRel } so relative links in
// namespaced markdown can be rewritten to the file's actual installed location
// (issue #2340). Returns null when the plan lacks the data needed to do so.
@@ -348,7 +360,7 @@ function applyInstallPlan(plan, dependencies = {}) {
&& isMarkdownPath(operation.destinationPath)
) {
const rewritten = rewriteRelativeLinks(
fs.readFileSync(operation.sourcePath, 'utf8'),
transformInstallContent(operation, fs.readFileSync(operation.sourcePath, 'utf8')),
{ sourceRel: operation.sourceRelativePath, index: linkIndex }
);
fs.writeFileSync(operation.destinationPath, rewritten, 'utf8');
@@ -385,6 +397,13 @@ function applyInstallPlan(plan, dependencies = {}) {
beforeInstallStateWrite({ plan: appliedPlan, state: finalState });
}
persistInstallState(plan.installStatePath, finalState);
const antigravityMigration = cleanupLegacyAntigravityInstall(appliedPlan);
const antigravityMigrationWarnings = antigravityMigration.detected && !antigravityMigration.complete
? [
'Legacy Antigravity migration is incomplete. ECC preserved modified, unverifiable, or unmanaged content under .agent; review and move anything you want to keep, then rerun the Antigravity install.',
...(Array.isArray(antigravityMigration.warnings) ? antigravityMigration.warnings : []),
]
: [];
return {
...plan,
@@ -395,6 +414,7 @@ function applyInstallPlan(plan, dependencies = {}) {
warnings: [
...(Array.isArray(plan.warnings) ? plan.warnings : []),
...migration.warnings,
...antigravityMigrationWarnings,
],
applied: true,
};
+11 -2
View File
@@ -315,7 +315,7 @@ function preflightManagedPlan(plan, dependencies = {}) {
};
}
function applyPreflightedManagedPlan(entry) {
async function applyPreflightedManagedPlan(entry) {
const preview = entry.preview && entry.preview.ownershipSnapshot
? entry.preview
: preflightManagedPlan(entry.preview.plan);
@@ -326,7 +326,7 @@ function applyPreflightedManagedPlan(entry) {
assertInstallStateUnchanged(preview.plan, expectedStateFingerprint)
);
return require('./install-executor').applyInstallPlan(preview.plan, {
const result = require('./install-executor').applyInstallPlan(preview.plan, {
beforeOperationWrite({ operation }) {
assertStateUnchanged();
const expected = preview.operations[operationIndex];
@@ -347,6 +347,15 @@ function applyPreflightedManagedPlan(entry) {
},
beforeInstallStateWrite: assertStateUnchanged,
});
const { projectCanonicalInstallState } = require('./install-state-store-sync');
const installStateProjection = await projectCanonicalInstallState(result.statePreview);
return {
...result,
installStateProjection,
warnings: installStateProjection.warning
? [...result.warnings, `Install health projection warning: ${installStateProjection.warning.message}`]
: result.warnings,
};
}
function defaultDependencies(options = {}) {
+180 -4
View File
@@ -1,6 +1,7 @@
'use strict';
const fs = require('fs');
const crypto = require('crypto');
const os = require('os');
const path = require('path');
const initSqlJs = require('sql.js');
@@ -8,8 +9,177 @@ const initSqlJs = require('sql.js');
const { applyMigrations, getAppliedMigrations } = require('./migrations');
const { createQueryApi } = require('./queries');
const { assertValidEntity, validateEntity } = require('./schema');
const {
buildInstallStateStoreRecord,
projectInstallState,
reconcileCurrentInstallState,
reconcileInstallStateProjections,
removeInstallStateProjection,
summarizeProjectedInstallHealth,
} = require('./install-state-projection');
const DEFAULT_STATE_STORE_RELATIVE_PATH = path.join('.claude', 'ecc', 'state.db');
const PRIVATE_DIRECTORY_MODE = 0o700;
const PRIVATE_FILE_MODE = 0o600;
function stateStorePathError(targetPath, detail) {
return new Error(`Unsafe state-store path '${targetPath}': ${detail}`);
}
function lstatIfPresent(targetPath) {
try {
return fs.lstatSync(targetPath);
} catch (error) {
if (error && error.code === 'ENOENT') {
return null;
}
throw error;
}
}
function isAllowedPlatformSymlink(targetPath, stats) {
if (process.platform !== 'darwin' || !stats || stats.uid !== 0) {
return false;
}
const allowedTargets = new Map([
['/var', '/private/var'],
['/tmp', '/private/tmp'],
['/etc', '/private/etc'],
]);
const expectedTarget = allowedTargets.get(targetPath);
if (!expectedTarget) {
return false;
}
try {
return fs.realpathSync(targetPath) === expectedTarget;
} catch (_error) {
return false;
}
}
function assertNotSymlink(targetPath, stats) {
if (stats && stats.isSymbolicLink()) {
if (isAllowedPlatformSymlink(targetPath, stats)) {
return;
}
throw stateStorePathError(targetPath, 'a symlink is not allowed');
}
}
function ensurePrivateDirectory(directoryPath) {
const absolutePath = path.resolve(directoryPath);
const parsed = path.parse(absolutePath);
const segments = absolutePath.slice(parsed.root.length).split(path.sep).filter(Boolean);
let currentPath = parsed.root;
for (const segment of segments) {
currentPath = path.join(currentPath, segment);
let stats = lstatIfPresent(currentPath);
assertNotSymlink(currentPath, stats);
if (!stats) {
try {
fs.mkdirSync(currentPath, { mode: PRIVATE_DIRECTORY_MODE });
} catch (error) {
if (!error || error.code !== 'EEXIST') {
throw error;
}
}
stats = fs.lstatSync(currentPath);
assertNotSymlink(currentPath, stats);
}
if (!stats.isDirectory() && !isAllowedPlatformSymlink(currentPath, stats)) {
throw stateStorePathError(currentPath, 'an intermediate component is not a directory');
}
}
return absolutePath;
}
function assertSafeDatabaseFile(dbPath) {
const stats = lstatIfPresent(dbPath);
assertNotSymlink(dbPath, stats);
if (stats && !stats.isFile()) {
throw stateStorePathError(dbPath, 'database path is not a regular file');
}
return stats;
}
function readDatabaseFile(dbPath) {
assertSafeDatabaseFile(dbPath);
const noFollow = fs.constants.O_NOFOLLOW || 0;
const fileDescriptor = fs.openSync(dbPath, fs.constants.O_RDONLY | noFollow);
try {
const stats = fs.fstatSync(fileDescriptor);
if (!stats.isFile()) {
throw stateStorePathError(dbPath, 'database path is not a regular file');
}
return fs.readFileSync(fileDescriptor);
} finally {
fs.closeSync(fileDescriptor);
}
}
function syncDirectory(directoryPath) {
if (process.platform === 'win32') {
return;
}
let fileDescriptor;
try {
fileDescriptor = fs.openSync(directoryPath, fs.constants.O_RDONLY);
fs.fsyncSync(fileDescriptor);
} catch (_error) {
// Some filesystems do not permit directory fsync. The file was still
// atomically replaced and fsynced before this durability best effort.
} finally {
if (fileDescriptor !== undefined) {
fs.closeSync(fileDescriptor);
}
}
}
function writeDatabaseFileAtomic(dbPath, data) {
const directoryPath = ensurePrivateDirectory(path.dirname(dbPath));
assertSafeDatabaseFile(dbPath);
const temporaryPath = path.join(
directoryPath,
`.${path.basename(dbPath)}.${process.pid}.${crypto.randomBytes(8).toString('hex')}.tmp`
);
const noFollow = fs.constants.O_NOFOLLOW || 0;
const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollow;
let fileDescriptor;
try {
fileDescriptor = fs.openSync(temporaryPath, flags, PRIVATE_FILE_MODE);
fs.writeFileSync(fileDescriptor, data);
fs.fchmodSync(fileDescriptor, PRIVATE_FILE_MODE);
fs.fsyncSync(fileDescriptor);
fs.closeSync(fileDescriptor);
fileDescriptor = undefined;
// A final-path symlink is never followed. If one appeared after this
// check, rename replaces the link itself rather than its target.
assertSafeDatabaseFile(dbPath);
fs.renameSync(temporaryPath, dbPath);
syncDirectory(directoryPath);
} finally {
if (fileDescriptor !== undefined) {
fs.closeSync(fileDescriptor);
}
try {
fs.unlinkSync(temporaryPath);
} catch (error) {
if (!error || error.code !== 'ENOENT') {
// Preserve the original persistence result. The temporary file is
// private, exclusively created, and never used as canonical state.
}
}
}
}
function resolveStateStorePath(options = {}) {
if (options.dbPath) {
@@ -40,7 +210,7 @@ function wrapSqlJsDatabase(rawDb, dbPath) {
}
const data = rawDb.export();
const buffer = Buffer.from(data);
fs.writeFileSync(dbPath, buffer);
writeDatabaseFileAtomic(dbPath, buffer);
}
const db = {
@@ -140,12 +310,12 @@ function wrapSqlJsDatabase(rawDb, dbPath) {
async function openDatabase(SQL, dbPath) {
if (dbPath !== ':memory:') {
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
ensurePrivateDirectory(path.dirname(dbPath));
}
let rawDb;
if (dbPath !== ':memory:' && fs.existsSync(dbPath)) {
const fileBuffer = fs.readFileSync(dbPath);
if (dbPath !== ':memory:' && assertSafeDatabaseFile(dbPath)) {
const fileBuffer = readDatabaseFile(dbPath);
rawDb = new SQL.Database(fileBuffer);
} else {
rawDb = new SQL.Database();
@@ -186,6 +356,12 @@ async function createStateStore(options = {}) {
module.exports = {
DEFAULT_STATE_STORE_RELATIVE_PATH,
buildInstallStateStoreRecord,
createStateStore,
projectInstallState,
reconcileCurrentInstallState,
reconcileInstallStateProjections,
removeInstallStateProjection,
resolveStateStorePath,
summarizeProjectedInstallHealth,
};
@@ -0,0 +1,361 @@
'use strict';
const path = require('path');
const MANAGED_FILE_HEALTH_CODES = new Set([
'missing-target-root',
'unsafe-managed-destination',
'unsafe-repair-source',
'missing-managed-files',
'drifted-managed-files',
'missing-source-files',
'unverified-managed-operations',
]);
function cloneJsonValue(value) {
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
}
function buildInstallStateStoreRecord(state) {
if (!state || !state.target || !state.request || !state.resolution || !state.source) {
throw new Error('Invalid canonical install-state: required projection fields are missing');
}
return {
targetId: state.target.id,
targetRoot: state.target.root,
profile: state.request.profile ?? null,
modules: Array.isArray(state.resolution.selectedModules)
? [...state.resolution.selectedModules]
: [],
operations: Array.isArray(state.operations)
? state.operations.map(operation => cloneJsonValue(operation))
: [],
installedAt: state.installedAt,
sourceVersion: state.source.repoVersion ?? null,
};
}
function warningFor(record, code, message) {
return {
code,
message,
targetId: record && record.adapter ? record.adapter.id : null,
targetRoot: record && record.targetRoot ? record.targetRoot : null,
installStatePath: record && record.installStatePath ? record.installStatePath : null,
};
}
function getRecordIdentity(record) {
if (
!record
|| !record.adapter
|| typeof record.adapter.id !== 'string'
|| record.adapter.id.length === 0
|| typeof record.targetRoot !== 'string'
|| record.targetRoot.length === 0
) {
return null;
}
return {
targetId: record.adapter.id,
targetRoot: record.targetRoot,
};
}
function pathsMatch(left, right) {
return typeof left === 'string'
&& left.length > 0
&& typeof right === 'string'
&& right.length > 0
&& path.resolve(left) === path.resolve(right);
}
function stateMatchesDiscoveryRecord(state, record) {
return Boolean(
state
&& state.target
&& state.target.id === record.adapter.id
&& pathsMatch(state.target.root, record.targetRoot)
&& pathsMatch(state.target.installStatePath, record.installStatePath)
);
}
function projectInstallState(store, state) {
try {
const record = buildInstallStateStoreRecord(state);
store.upsertInstallState(record);
return {
status: 'projected',
record,
warning: null,
};
} catch (error) {
return {
status: 'warning',
record: null,
warning: {
code: 'projection-write-failed',
message: error.message,
targetId: state && state.target ? state.target.id : null,
targetRoot: state && state.target ? state.target.root : null,
installStatePath: state && state.target ? state.target.installStatePath : null,
},
};
}
}
function removeInstallStateProjection(store, identity) {
try {
return {
status: 'removed',
removed: store.deleteInstallState(identity),
warning: null,
};
} catch (error) {
return {
status: 'warning',
removed: false,
warning: {
code: 'projection-delete-failed',
message: error.message,
targetId: identity && identity.targetId ? identity.targetId : null,
targetRoot: identity && identity.targetRoot ? identity.targetRoot : null,
installStatePath: null,
},
};
}
}
function createReconciliationResult(discoveredCount) {
return {
status: 'ok',
discoveredCount,
projectedCount: 0,
removedCount: 0,
warningCount: 0,
warnings: [],
scopedTargets: [],
managedFileHealth: [],
};
}
function addWarning(result, warning) {
return {
...result,
status: 'warning',
warningCount: result.warningCount + 1,
warnings: [...result.warnings, warning],
};
}
function removeDiscoverableProjection(store, identity, result) {
const removal = removeInstallStateProjection(store, identity);
if (removal.warning) {
return addWarning(result, removal.warning);
}
return {
...result,
removedCount: result.removedCount + (removal.removed ? 1 : 0),
};
}
/**
* Reconcile only the identities enumerated by install target discovery.
* Canonical JSON files remain authoritative, and rows from other home or
* project scopes are deliberately left unchanged.
*/
function reconcileInstallStateProjections(store, discoveryRecords) {
const records = Array.isArray(discoveryRecords) ? discoveryRecords : [];
let result = {
...createReconciliationResult(records.length),
scopedTargets: records.map(getRecordIdentity).filter(Boolean),
};
for (const record of records) {
const identity = getRecordIdentity(record);
if (!identity) {
result = addWarning(result, warningFor(
record,
'invalid-discovery-record',
'Install target discovery returned an invalid target identity'
));
continue;
}
if (!record.exists) {
result = removeDiscoverableProjection(store, identity, result);
continue;
}
if (record.error || !record.state) {
result = removeDiscoverableProjection(store, identity, result);
result = addWarning(result, warningFor(
record,
'invalid-install-state',
record.error || 'Canonical install-state could not be read'
));
continue;
}
if (!stateMatchesDiscoveryRecord(record.state, record)) {
result = removeDiscoverableProjection(store, identity, result);
result = addWarning(result, warningFor(
record,
'install-state-identity-mismatch',
'Canonical install-state identity does not match its discovered target'
));
continue;
}
const projection = projectInstallState(store, record.state);
if (projection.warning) {
result = addWarning(result, projection.warning);
continue;
}
result = {
...result,
projectedCount: result.projectedCount + 1,
};
}
return result;
}
function inspectManagedFileHealth(options) {
const buildReport = options.buildDoctorReport
|| require('../install-lifecycle').buildDoctorReport;
const report = buildReport({
repoRoot: options.repoRoot,
homeDir: options.homeDir,
projectRoot: options.projectRoot,
targets: options.targets,
});
return report.results.map(result => {
const issues = Array.isArray(result.issues)
? result.issues.filter(issue => MANAGED_FILE_HEALTH_CODES.has(issue.code))
: [];
const status = issues.some(issue => issue.severity === 'error')
? 'error'
: issues.some(issue => issue.severity === 'warning') ? 'warning' : 'ok';
return {
targetId: result.adapter.id,
targetRoot: result.targetRoot,
status,
issues: issues.map(issue => cloneJsonValue(issue)),
};
});
}
function identityKey(identity) {
return `${identity.targetId}\u0000${path.resolve(identity.targetRoot)}`;
}
function summarizeProjectedInstallHealth(installHealth, reconciliation) {
const healthEntries = Array.isArray(reconciliation && reconciliation.managedFileHealth)
? reconciliation.managedFileHealth
: [];
const healthByIdentity = new Map(healthEntries.map(entry => [identityKey(entry), entry]));
const healthCheckFailed = Boolean(
reconciliation
&& Array.isArray(reconciliation.warnings)
&& reconciliation.warnings.some(warning => warning.code === 'install-health-check-failed')
);
const scopedIdentities = new Set(
Array.isArray(reconciliation && reconciliation.scopedTargets)
? reconciliation.scopedTargets.map(identityKey)
: []
);
const installations = installHealth.installations.map(installation => {
const key = identityKey(installation);
const canonicalHealth = healthByIdentity.get(key);
if (canonicalHealth) {
return {
...installation,
status: canonicalHealth.status === 'ok' ? installation.status : 'warning',
canonicalStatus: canonicalHealth.status,
issues: canonicalHealth.issues.map(issue => cloneJsonValue(issue)),
};
}
if (healthCheckFailed && scopedIdentities.has(key)) {
return {
...installation,
status: 'warning',
canonicalStatus: 'unverified',
issues: [{
severity: 'warning',
code: 'install-health-check-failed',
message: 'Canonical managed-file health could not be verified',
}],
};
}
return installation;
});
const healthyCount = installations.filter(installation => installation.status === 'healthy').length;
const warningCount = installations.length - healthyCount;
return {
...installHealth,
status: installations.length === 0
? 'missing'
: warningCount > 0 ? 'warning' : 'healthy',
healthyCount,
warningCount,
installations,
};
}
function reconcileCurrentInstallState(store, options = {}) {
try {
const discover = options.discoverInstalledStates
|| require('../install-lifecycle').discoverInstalledStates;
const records = discover({
homeDir: options.homeDir,
projectRoot: options.projectRoot,
targets: options.targets,
});
let result = reconcileInstallStateProjections(store, records);
try {
result = {
...result,
managedFileHealth: inspectManagedFileHealth(options),
};
} catch (error) {
result = addWarning(result, {
code: 'install-health-check-failed',
message: error.message,
targetId: null,
targetRoot: null,
installStatePath: null,
});
}
return result;
} catch (error) {
return {
...createReconciliationResult(0),
status: 'warning',
warningCount: 1,
warnings: [{
code: 'install-state-discovery-failed',
message: error.message,
targetId: null,
targetRoot: null,
installStatePath: null,
}],
};
}
}
module.exports = {
buildInstallStateStoreRecord,
projectInstallState,
reconcileCurrentInstallState,
reconcileInstallStateProjections,
removeInstallStateProjection,
stateMatchesDiscoveryRecord,
summarizeProjectedInstallHealth,
};
+38
View File
@@ -348,6 +348,23 @@ function normalizeInstallStateInput(installState) {
};
}
function normalizeInstallStateIdentity(identity) {
if (!identity || typeof identity !== 'object') {
throw new Error('Invalid installState identity: expected targetId and targetRoot');
}
const targetId = identity.targetId;
const targetRoot = identity.targetRoot;
if (typeof targetId !== 'string' || targetId.length === 0) {
throw new Error('Invalid installState identity: targetId must be a non-empty string');
}
if (typeof targetRoot !== 'string' || targetRoot.length === 0) {
throw new Error('Invalid installState identity: targetRoot must be a non-empty string');
}
return { targetId, targetRoot };
}
function normalizeGovernanceEventInput(governanceEvent) {
return {
id: governanceEvent.id,
@@ -432,6 +449,11 @@ function createQueryApi(db) {
FROM install_state
ORDER BY installed_at DESC, target_id ASC
`);
const getInstallStateStatement = db.prepare(`
SELECT target_id
FROM install_state
WHERE target_id = ? AND target_root = ?
`);
const countPendingGovernanceStatement = db.prepare(`
SELECT COUNT(*) AS total_count
FROM governance_events
@@ -617,6 +639,10 @@ function createQueryApi(db) {
installed_at = excluded.installed_at,
source_version = excluded.source_version
`);
const deleteInstallStateStatement = db.prepare(`
DELETE FROM install_state
WHERE target_id = @target_id AND target_root = @target_root
`);
const insertGovernanceEventStatement = db.prepare(`
INSERT INTO governance_events (
@@ -778,6 +804,18 @@ function createQueryApi(db) {
}
return {
deleteInstallState(identity) {
const normalized = normalizeInstallStateIdentity(identity);
const existing = getInstallStateStatement.get(normalized.targetId, normalized.targetRoot);
if (!existing) {
return false;
}
deleteInstallStateStatement.run({
target_id: normalized.targetId,
target_root: normalized.targetRoot,
});
return true;
},
getSessionById,
getSessionDetail,
getWorkItemById,
+9 -1
View File
@@ -71,7 +71,7 @@ function printHuman(result) {
}
}
function main() {
async function main() {
try {
const options = parseArgs(process.argv);
if (options.help) {
@@ -85,6 +85,14 @@ function main() {
targets: options.targets,
dryRun: options.dryRun,
});
if (!options.dryRun) {
const { reconcileCanonicalInstallStates } = require('./lib/install-state-store-sync');
result.installStateProjection = await reconcileCanonicalInstallStates({
homeDir: process.env.HOME || os.homedir(),
projectRoot: process.cwd(),
targets: options.targets,
});
}
const hasErrors = result.summary.errorCount > 0;
if (options.json) {
+63 -6
View File
@@ -5,6 +5,10 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
const { createStateStore } = require('./lib/state-store');
const {
reconcileCurrentInstallState,
summarizeProjectedInstallHealth,
} = require('./lib/state-store/install-state-projection');
function showHelp(exitCode = 0) {
console.log(`
@@ -112,11 +116,17 @@ function printSkillRuns(section) {
}
}
function printInstallHealth(section) {
function printInstallHealth(section, projection) {
console.log(`Install health: ${section.status}`);
console.log(` Targets recorded: ${section.totalCount}`);
console.log(` Healthy: ${section.healthyCount}`);
console.log(` Warning: ${section.warningCount}`);
if (projection) {
console.log(` Projection: ${projection.status}`);
for (const warning of projection.warnings) {
console.log(` - [warning] ${warning.code}: ${warning.message}`);
}
}
if (section.installations.length === 0) {
console.log(' Installations: none');
@@ -130,6 +140,9 @@ function printInstallHealth(section) {
console.log(` Profile: ${installation.profile || '(custom)'}`);
console.log(` Modules: ${installation.moduleCount}`);
console.log(` Source version: ${installation.sourceVersion || '(unknown)'}`);
for (const issue of installation.issues || []) {
console.log(` - [${issue.severity}] ${issue.code}: ${issue.message}`);
}
}
}
@@ -252,7 +265,7 @@ function printHuman(payload) {
console.log();
printSkillRuns(payload.skillRuns);
console.log();
printInstallHealth(payload.installHealth);
printInstallHealth(payload.installHealth, payload.installStateProjection);
console.log();
printGovernance(payload.governance);
console.log();
@@ -336,6 +349,13 @@ function renderMarkdown(payload) {
`Warning: ${payload.installHealth.warningCount}`
);
if (payload.installStateProjection) {
lines.push(`Projection: ${payload.installStateProjection.status}`);
for (const warning of payload.installStateProjection.warnings) {
lines.push(`- [warning] ${warning.code}: ${warning.message}`);
}
}
if (payload.installHealth.installations.length === 0) {
lines.push('', 'Installations: none');
} else {
@@ -346,6 +366,9 @@ function renderMarkdown(payload) {
lines.push(` - Profile: ${installation.profile || '(custom)'}`);
lines.push(` - Modules: ${installation.moduleCount}`);
lines.push(` - Source version: ${installation.sourceVersion || '(unknown)'}`);
for (const issue of installation.issues || []) {
lines.push(` - [${issue.severity}] ${issue.code}: ${issue.message}`);
}
}
}
@@ -442,14 +465,48 @@ async function main() {
homeDir: process.env.HOME || os.homedir(),
});
const payload = {
dbPath: store.dbPath,
...store.getStatus({
const installStateProjection = reconcileCurrentInstallState(store, {
homeDir: process.env.HOME || os.homedir(),
projectRoot: process.cwd(),
});
const storedStatus = store.getStatus({
activeLimit: options.limit,
recentSkillRunLimit: 20,
pendingLimit: options.limit,
workItemLimit: options.limit,
}),
});
const installHealth = summarizeProjectedInstallHealth(
storedStatus.installHealth,
installStateProjection
);
const installWarningDelta = installHealth.warningCount
- storedStatus.installHealth.warningCount;
const status = {
...storedStatus,
installHealth,
readiness: installWarningDelta === 0
? storedStatus.readiness
: {
...storedStatus.readiness,
status: 'attention',
attentionCount: storedStatus.readiness.attentionCount + installWarningDelta,
warningInstallations: installHealth.warningCount,
},
};
const projectionWarningCount = installStateProjection.warningCount;
const payload = {
dbPath: store.dbPath,
...status,
readiness: projectionWarningCount === 0
? status.readiness
: {
...status.readiness,
status: 'attention',
attentionCount: status.readiness.attentionCount + projectionWarningCount,
installProjectionWarnings: projectionWarningCount,
},
installStateProjection,
};
payload.githubCoordination = summarizeGithubCoordination(payload.workItems);
+51 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
set -Eeuo pipefail
# Sync Everything Claude Code (ECC) assets into a local Codex CLI setup.
# - Backs up ~/.codex config and AGENTS.md
@@ -44,6 +44,7 @@ PROMPTS_DEST="$CODEX_HOME/prompts"
BASELINE_MERGE_SCRIPT="$REPO_ROOT/scripts/codex/merge-codex-config.js"
HOOKS_INSTALLER="$REPO_ROOT/scripts/codex/install-global-git-hooks.sh"
SANITY_CHECKER="$REPO_ROOT/scripts/codex/check-codex-global-state.sh"
LEGACY_STATE_HELPER="$REPO_ROOT/scripts/codex/legacy-sync-state.js"
CURSOR_RULES_DIR="$REPO_ROOT/.cursor/rules"
STAMP="$(date +%Y%m%d-%H%M%S)"
@@ -169,6 +170,7 @@ require_path "$PROMPTS_SRC" "ECC commands directory"
require_path "$BASELINE_MERGE_SCRIPT" "ECC Codex baseline merge script"
require_path "$HOOKS_INSTALLER" "ECC global git hooks installer"
require_path "$SANITY_CHECKER" "ECC global sanity checker"
require_path "$LEGACY_STATE_HELPER" "ECC legacy sync state helper"
require_path "$CURSOR_RULES_DIR" "ECC Cursor rules directory"
require_path "$CONFIG_FILE" "Codex config.toml"
require_path "$MCP_MERGE_SCRIPT" "ECC MCP merge script"
@@ -189,6 +191,40 @@ if [[ -f "$AGENTS_FILE" ]]; then
run_or_echo cp "$AGENTS_FILE" "$BACKUP_DIR/AGENTS.md"
fi
LEGACY_STATE_PATH=""
record_managed_path() {
local managed_path="$1"
if [[ "$MODE" == "apply" ]]; then
node "$LEGACY_STATE_HELPER" record --state "$LEGACY_STATE_PATH" --path "$managed_path"
fi
}
if [[ "$MODE" == "apply" ]]; then
previous_hooks_path="$(git config --global core.hooksPath || true)"
LEGACY_STATE_PATH="$(
node "$LEGACY_STATE_HELPER" begin \
--codex-home "$CODEX_HOME" \
--backup-dir "$BACKUP_DIR" \
--previous-hooks-path "$previous_hooks_path" \
--installed-hooks-path "${ECC_GLOBAL_HOOKS_DIR:-$CODEX_HOME/git-hooks}"
)"
record_managed_path "$CONFIG_FILE"
record_managed_path "$AGENTS_FILE"
rollback_legacy_sync() {
local exit_status="${1:-1}"
trap - ERR INT TERM
log "Install interrupted; restoring the pre-sync Codex state"
if ! node "$LEGACY_STATE_HELPER" rollback --state "$LEGACY_STATE_PATH"; then
log "ERROR: Automatic rollback was partial. Review: $LEGACY_STATE_PATH"
fi
exit "$exit_status"
}
trap 'rollback_legacy_sync $?' ERR
trap 'rollback_legacy_sync 130' INT
trap 'rollback_legacy_sync 143' TERM
fi
ECC_BEGIN_MARKER="<!-- BEGIN ECC -->"
ECC_END_MARKER="<!-- END ECC -->"
@@ -276,11 +312,16 @@ fi
log "Syncing Codex navigation guide"
run_or_echo mkdir -p "$(dirname "$CODEX_NAV_GUIDE_DEST")"
record_managed_path "$CODEX_NAV_GUIDE_DEST"
run_or_echo cp "$CODEX_NAV_GUIDE_SRC" "$CODEX_NAV_GUIDE_DEST"
record_managed_path "$CODEX_COMMAND_AGENT_MAP_DEST"
run_or_echo cp "$CODEX_COMMAND_AGENT_MAP_SRC" "$CODEX_COMMAND_AGENT_MAP_DEST"
record_managed_path "$CODEX_COMMANDS_QUICK_REF_DEST"
run_or_echo cp "$CODEX_COMMANDS_QUICK_REF_SRC" "$CODEX_COMMANDS_QUICK_REF_DEST"
record_managed_path "$CODEX_CONTRIBUTING_DEST"
run_or_echo cp "$CODEX_CONTRIBUTING_SRC" "$CODEX_CONTRIBUTING_DEST"
run_or_echo mkdir -p "$(dirname "$CODEX_PR_TEMPLATE_DEST")"
record_managed_path "$CODEX_PR_TEMPLATE_DEST"
run_or_echo cp "$CODEX_PR_TEMPLATE_SRC" "$CODEX_PR_TEMPLATE_DEST"
log "Syncing sample Codex agent role files"
@@ -292,6 +333,7 @@ for agent_file in "$CODEX_AGENTS_SRC"/*.toml; do
if [[ -e "$dest" ]]; then
log "Keeping existing Codex agent role file: $dest"
else
record_managed_path "$dest"
run_or_echo cp "$agent_file" "$dest"
fi
done
@@ -303,6 +345,7 @@ done
log "Generating prompt files from ECC commands"
run_or_echo mkdir -p "$PROMPTS_DEST"
manifest="$PROMPTS_DEST/ecc-prompts-manifest.txt"
record_managed_path "$manifest"
if [[ "$MODE" == "dry-run" ]]; then
printf '[dry-run] > %s\n' "$manifest"
else
@@ -316,6 +359,7 @@ while IFS= read -r -d '' command_file; do
if [[ "$MODE" == "dry-run" ]]; then
printf '[dry-run] generate %s from %s\n' "$out" "$command_file"
else
record_managed_path "$out"
generate_prompt_file "$command_file" "$out" "$name"
printf 'ecc-%s.md\n' "$name" >> "$manifest"
fi
@@ -328,6 +372,7 @@ fi
log "Generating Codex tool prompts + optional rule-pack prompts"
extension_manifest="$PROMPTS_DEST/ecc-extension-prompts-manifest.txt"
record_managed_path "$extension_manifest"
if [[ "$MODE" == "dry-run" ]]; then
printf '[dry-run] > %s\n' "$extension_manifest"
else
@@ -342,6 +387,7 @@ write_extension_prompt() {
if [[ "$MODE" == "dry-run" ]]; then
printf '[dry-run] generate %s\n' "$file"
else
record_managed_path "$file"
cat > "$file"
printf '%s\n' "$name" >> "$extension_manifest"
fi
@@ -531,6 +577,8 @@ if [[ "$MODE" == "dry-run" ]]; then
ECC_GLOBAL_HOOKS_DIR="${ECC_GLOBAL_HOOKS_DIR:-$CODEX_HOME/git-hooks}" \
"$HOOKS_INSTALLER" --dry-run
else
record_managed_path "${ECC_GLOBAL_HOOKS_DIR:-$CODEX_HOME/git-hooks}/pre-commit"
record_managed_path "${ECC_GLOBAL_HOOKS_DIR:-$CODEX_HOME/git-hooks}/pre-push"
HOME="$HOME" \
CODEX_HOME="$CODEX_HOME" \
AGENTS_HOME="${AGENTS_HOME:-$HOME/.agents}" \
@@ -554,5 +602,7 @@ log "Backup saved at: $BACKUP_DIR"
log "Prompts generated: $((prompt_count + extension_count)) (commands: $prompt_count, extensions: $extension_count)"
if [[ "$MODE" == "apply" ]]; then
node "$LEGACY_STATE_HELPER" finalize --state "$LEGACY_STATE_PATH"
trap - ERR INT TERM
log "Done. Restart Codex CLI to reload AGENTS, prompts, and MCP servers."
fi
+50 -6
View File
@@ -4,12 +4,14 @@ const os = require('os');
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');
function showHelp(exitCode = 0) {
console.log(`
Usage: node scripts/uninstall.js [--target <${SUPPORTED_INSTALL_TARGETS.join('|')}>] [--dry-run] [--json]
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.
`);
process.exit(exitCode);
}
@@ -20,6 +22,7 @@ function parseArgs(argv) {
targets: [],
dryRun: false,
json: false,
legacyCodexSync: false,
help: false,
};
@@ -33,6 +36,8 @@ function parseArgs(argv) {
parsed.dryRun = true;
} else if (arg === '--json') {
parsed.json = true;
} else if (arg === '--legacy-codex-sync') {
parsed.legacyCodexSync = true;
} else if (arg === '--help' || arg === '-h') {
parsed.help = true;
} else {
@@ -60,34 +65,73 @@ function printHuman(result) {
continue;
}
const paths = result.dryRun ? entry.plannedRemovals : entry.removedPaths;
if (entry.warning) {
console.log(` Warning: ${entry.warning}`);
}
if (Array.isArray(entry.retainedPaths) && entry.retainedPaths.length > 0) {
console.log(` Retained paths: ${entry.retainedPaths.length}`);
for (const retainedPath of entry.retainedPaths) {
console.log(` - ${retainedPath}`);
}
}
const candidatePaths = result.dryRun ? entry.plannedRemovals : entry.removedPaths;
const paths = Array.isArray(candidatePaths) ? candidatePaths : [];
console.log(` ${result.dryRun ? 'Planned removals' : 'Removed paths'}: ${paths.length}`);
}
console.log(`\nSummary: checked=${result.summary.checkedCount}, ${result.dryRun ? 'planned' : 'uninstalled'}=${result.dryRun ? result.summary.plannedRemovalCount : result.summary.uninstalledCount}, errors=${result.summary.errorCount}`);
console.log(`\nSummary: checked=${result.summary.checkedCount}, ${result.dryRun ? 'planned' : 'uninstalled'}=${result.dryRun ? result.summary.plannedRemovalCount : result.summary.uninstalledCount}, partial=${result.summary.partialCount}, errors=${result.summary.errorCount}`);
if (!result.dryRun) {
console.log(`\n${exitFeedbackLines().join('\n')}`);
}
}
function main() {
async function main() {
try {
const options = parseArgs(process.argv);
if (options.help) {
showHelp(0);
}
const result = uninstallInstalledStates({
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,
});
const hasErrors = result.summary.errorCount > 0;
if (!options.dryRun && !options.legacyCodexSync) {
const { reconcileCanonicalInstallStates } = require('./lib/install-state-store-sync');
result.installStateProjection = await reconcileCanonicalInstallStates({
homeDir: process.env.HOME || os.homedir(),
projectRoot: process.cwd(),
targets: options.targets,
});
}
const hasErrors = options.legacyCodexSync
? 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 {
printHuman(result);
}
+100 -9
View File
@@ -1,6 +1,6 @@
---
name: repo-scan
description: Cross-stack source code asset audit — classifies every file, detects embedded third-party libraries, and delivers actionable four-level verdicts per module with interactive HTML reports. Use when an unfamiliar or inherited repository needs a file-level audit of what each module is and what third-party code it embeds.
description: Bootstrap pointer that installs the external repo-scan skill from a pinned, reviewable commit. Use when repo-scan must be installed before running its cross-stack source-code asset audit; this ECC pointer does not perform the audit itself.
metadata:
origin: community
---
@@ -19,18 +19,109 @@ metadata:
## Installation
```bash
# Fetch only the pinned commit for reproducibility
mkdir -p ~/.claude/skills/repo-scan
git init repo-scan
cd repo-scan
git remote add origin https://github.com/haibindev/repo-scan.git
git fetch --depth 1 origin 2742664
git checkout --detach FETCH_HEAD
cp -r . ~/.claude/skills/repo-scan
# Clone first so the pinned commit can be reviewed before installation
set -euo pipefail
REPO_SCAN_COMMIT=2742664ebcad1450c208eda0ae45d3c17fad5dd8
REPO_SCAN_INSTALL_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/repo-scan"
REPO_SCAN_INSTALL_PARENT="$(dirname "$REPO_SCAN_INSTALL_DIR")"
mkdir -p "$REPO_SCAN_INSTALL_PARENT"
REPO_SCAN_TMP="$(mktemp -d "$REPO_SCAN_INSTALL_PARENT/.repo-scan-install.XXXXXX")"
REPO_SCAN_TOKEN="${REPO_SCAN_TMP##*.}"
REPO_SCAN_STAGE="$REPO_SCAN_TMP/stage-$REPO_SCAN_TOKEN"
REPO_SCAN_BACKUP="$REPO_SCAN_TMP/backup-$REPO_SCAN_TOKEN"
REPO_SCAN_LOCK="$REPO_SCAN_INSTALL_PARENT/.repo-scan-install.lock"
REPO_SCAN_KEEP_TMP=0
REPO_SCAN_LOCK_HELD=0
REPO_SCAN_MV_HAS_NO_TARGET=0
cleanup_repo_scan_install() {
if [ "$REPO_SCAN_KEEP_TMP" -eq 0 ]; then
rm -rf -- "$REPO_SCAN_TMP"
fi
if [ "$REPO_SCAN_LOCK_HELD" -eq 1 ] && ! rmdir -- "$REPO_SCAN_LOCK"; then
printf 'Could not release installation lock at %s\n' "$REPO_SCAN_LOCK" >&2
fi
}
trap cleanup_repo_scan_install EXIT
mkdir "$REPO_SCAN_TMP/mv-probe-source"
if mv -T -- "$REPO_SCAN_TMP/mv-probe-source" \
"$REPO_SCAN_TMP/mv-probe-destination" 2>/dev/null; then
REPO_SCAN_MV_HAS_NO_TARGET=1
rmdir "$REPO_SCAN_TMP/mv-probe-destination"
else
rmdir "$REPO_SCAN_TMP/mv-probe-source"
fi
move_repo_scan_dir() {
REPO_SCAN_MOVE_SOURCE=$1
REPO_SCAN_MOVE_DESTINATION=$2
REPO_SCAN_MOVE_NAME=${REPO_SCAN_MOVE_SOURCE##*/}
if [ -e "$REPO_SCAN_MOVE_DESTINATION" ] || [ -L "$REPO_SCAN_MOVE_DESTINATION" ]; then
return 1
fi
if [ "$REPO_SCAN_MV_HAS_NO_TARGET" -eq 1 ]; then
mv -T -- "$REPO_SCAN_MOVE_SOURCE" "$REPO_SCAN_MOVE_DESTINATION"
return
fi
if ! mv -- "$REPO_SCAN_MOVE_SOURCE" "$REPO_SCAN_MOVE_DESTINATION"; then
return 1
fi
if [ -e "$REPO_SCAN_MOVE_DESTINATION/$REPO_SCAN_MOVE_NAME" ] || \
[ -L "$REPO_SCAN_MOVE_DESTINATION/$REPO_SCAN_MOVE_NAME" ]; then
if ! mv -- "$REPO_SCAN_MOVE_DESTINATION/$REPO_SCAN_MOVE_NAME" \
"$REPO_SCAN_MOVE_SOURCE"; then
REPO_SCAN_KEEP_TMP=1
printf 'Move conflict recovery failed; staged data remains at %s\n' \
"$REPO_SCAN_MOVE_DESTINATION/$REPO_SCAN_MOVE_NAME" >&2
fi
return 1
fi
}
git clone --filter=blob:none --no-checkout \
https://github.com/haibindev/repo-scan.git "$REPO_SCAN_TMP/source"
git -C "$REPO_SCAN_TMP/source" checkout --detach "$REPO_SCAN_COMMIT"
mkdir -p "$REPO_SCAN_STAGE"
git -C "$REPO_SCAN_TMP/source" archive "$REPO_SCAN_COMMIT" | \
tar -xf - -C "$REPO_SCAN_STAGE"
# Review "$REPO_SCAN_TMP/source" before approving installation.
printf 'Type install to replace %s after reviewing the pinned source: ' \
"$REPO_SCAN_INSTALL_DIR" >&2
read -r REPO_SCAN_CONFIRM
if [ "$REPO_SCAN_CONFIRM" != install ]; then
printf 'Installation cancelled.\n' >&2
exit 1
fi
if ! mkdir -- "$REPO_SCAN_LOCK" 2>/dev/null; then
printf 'Another repo-scan installation holds the lock at %s\n' \
"$REPO_SCAN_LOCK" >&2
exit 1
fi
REPO_SCAN_LOCK_HELD=1
if [ -e "$REPO_SCAN_INSTALL_DIR" ] || [ -L "$REPO_SCAN_INSTALL_DIR" ]; then
move_repo_scan_dir "$REPO_SCAN_INSTALL_DIR" "$REPO_SCAN_BACKUP"
fi
if ! move_repo_scan_dir "$REPO_SCAN_STAGE" "$REPO_SCAN_INSTALL_DIR"; then
if [ -e "$REPO_SCAN_BACKUP" ] || [ -L "$REPO_SCAN_BACKUP" ]; then
if [ -e "$REPO_SCAN_INSTALL_DIR" ] || [ -L "$REPO_SCAN_INSTALL_DIR" ]; then
REPO_SCAN_KEEP_TMP=1
printf 'Replacement failed and target was recreated; previous installation preserved at %s\n' \
"$REPO_SCAN_BACKUP" >&2
elif ! move_repo_scan_dir "$REPO_SCAN_BACKUP" "$REPO_SCAN_INSTALL_DIR"; then
REPO_SCAN_KEEP_TMP=1
printf 'Replacement and rollback failed; previous installation preserved at %s\n' \
"$REPO_SCAN_BACKUP" >&2
fi
fi
exit 1
fi
```
> Review the source before installing any agent skill.
Installation completes only the bootstrap. Reload your agent harness, then invoke `repo-scan` again. This ECC pointer installs the external skill but does not run a scan itself.
## Core Capabilities
| Capability | Description |
+448
View File
@@ -0,0 +1,448 @@
#!/usr/bin/env node
'use strict';
const assert = require('assert');
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { pathToFileURL } = require('url');
const { spawnSync } = require('child_process');
const PACKAGE_NAME = 'ecc-universal';
const HASH_PATTERN = /^[a-f0-9]{64}$/i;
const PACKAGE_PATH_PATTERN = /^release-artifacts\/ecc-universal-[0-9A-Za-z.+-]+\.tgz$/;
function parseEnvironment(environment = process.env, cwd = process.cwd()) {
const packageValue = environment.ECC_RELEASE_PACKAGE;
const hashValue = environment.ECC_RELEASE_SHA256;
if (!packageValue) {
throw new Error('ECC_RELEASE_PACKAGE must name the downloaded release .tgz');
}
if (!PACKAGE_PATH_PATTERN.test(String(packageValue))) {
throw new Error('ECC_RELEASE_PACKAGE must name one ECC .tgz under release-artifacts');
}
if (!HASH_PATTERN.test(hashValue || '')) {
throw new Error('ECC_RELEASE_SHA256 must be a 64-character SHA-256 digest');
}
return {
packagePath: path.resolve(cwd, packageValue),
expectedSha256: hashValue.toLowerCase(),
};
}
function assertDownloadedArtifact(packagePath, cwd) {
const artifactRoot = path.resolve(cwd, 'release-artifacts');
const packageStat = fs.lstatSync(packagePath);
if (!packageStat.isFile() || packageStat.isSymbolicLink()) {
throw new Error('Release package must be a regular, non-symlink file');
}
const realArtifactRoot = fs.realpathSync(artifactRoot);
const realPackagePath = fs.realpathSync(packagePath);
const relativePath = path.relative(realArtifactRoot, realPackagePath);
if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
throw new Error('Release package escapes release-artifacts');
}
const archives = fs.readdirSync(realArtifactRoot).filter(name => name.endsWith('.tgz'));
if (archives.length !== 1 || archives[0] !== path.basename(realPackagePath)) {
throw new Error('Expected exactly one downloaded release archive');
}
}
function hashFile(filePath) {
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
}
function assertHash(actualSha256, expectedSha256) {
if (actualSha256 !== expectedSha256) {
throw new Error(
`Downloaded artifact SHA-256 ${actualSha256} does not match packed artifact ${expectedSha256}`
);
}
}
function createLifecycleEnvironment(baseEnvironment, homeDir) {
const environment = {};
const inheritedNames = [
'CI',
'ComSpec',
'LANG',
'LC_ALL',
'NO_COLOR',
'PATH',
'Path',
'PATHEXT',
'SystemRoot',
'TEMP',
'TMP',
'TMPDIR',
'WINDIR',
];
for (const name of inheritedNames) {
if (baseEnvironment[name] !== undefined) {
environment[name] = baseEnvironment[name];
}
}
return {
...environment,
HOME: homeDir,
USERPROFILE: homeDir,
APPDATA: path.join(homeDir, 'AppData', 'Roaming'),
LOCALAPPDATA: path.join(homeDir, 'AppData', 'Local'),
XDG_CONFIG_HOME: path.join(homeDir, '.config'),
XDG_DATA_HOME: path.join(homeDir, '.local', 'share'),
NPM_CONFIG_CACHE: path.join(homeDir, '.npm'),
NPM_CONFIG_USERCONFIG: path.join(homeDir, '.npmrc'),
};
}
function runProcess(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd,
env: options.env,
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024,
stdio: ['ignore', 'pipe', 'pipe'],
});
if (result.error) {
throw result.error;
}
const expectedStatus = options.expectedStatus ?? 0;
if (result.status !== expectedStatus) {
throw new Error([
`${options.label || command} exited ${result.status}, expected ${expectedStatus}.`,
result.stdout ? `stdout:\n${result.stdout}` : '',
result.stderr ? `stderr:\n${result.stderr}` : '',
].filter(Boolean).join('\n'));
}
return result;
}
function getNpmExecInvocation(publicArgs, environment, platform = process.platform) {
const npmArgs = ['exec', '--offline', '--yes=false', '--', ...publicArgs];
if (platform !== 'win32') {
return { command: 'npm', args: npmArgs };
}
const commandParts = ['npm', ...npmArgs];
for (const part of commandParts) {
if (!/^[A-Za-z0-9_.=+/-]+$/.test(part)) {
throw new Error(`Unsafe npm exec argument for Windows lifecycle: ${part}`);
}
}
return {
command: environment.ComSpec || 'cmd.exe',
args: ['/d', '/s', '/c', commandParts.join(' ')],
};
}
function installPackage(projectDir, packagePath, environment) {
const projectManifest = {
name: 'ecc-packed-artifact-lifecycle',
version: '1.0.0',
private: true,
dependencies: {
[PACKAGE_NAME]: pathToFileURL(packagePath).href,
},
};
fs.writeFileSync(
path.join(projectDir, 'package.json'),
`${JSON.stringify(projectManifest, null, 2)}\n`,
'utf8'
);
if (process.platform === 'win32') {
runProcess(
environment.ComSpec || 'cmd.exe',
['/d', '/s', '/c', 'npm install --no-audit --no-fund'],
{ cwd: projectDir, env: environment, label: 'npm install packed artifact' }
);
return;
}
runProcess('npm', ['install', '--no-audit', '--no-fund'], {
cwd: projectDir,
env: environment,
label: 'npm install packed artifact',
});
}
function parseJsonOutput(result, label) {
try {
return JSON.parse(result.stdout);
} catch (error) {
throw new Error(`${label} did not emit valid JSON: ${error.message}\n${result.stdout}`);
}
}
function resolveManagedExistingPath(destinationPath, cursorRoot) {
const normalizedRoot = fs.realpathSync(cursorRoot);
const lexicalPath = path.resolve(destinationPath);
const lexicalRelativePath = path.relative(normalizedRoot, lexicalPath);
if (
lexicalRelativePath === ''
|| lexicalRelativePath.startsWith('..')
|| path.isAbsolute(lexicalRelativePath)
|| !fs.existsSync(lexicalPath)
) {
return null;
}
const pathStat = fs.lstatSync(lexicalPath);
if (pathStat.isSymbolicLink()) {
throw new Error(`Managed lifecycle path must not be a symlink: ${lexicalPath}`);
}
const realPath = fs.realpathSync(lexicalPath);
const realRelativePath = path.relative(normalizedRoot, realPath);
if (realRelativePath.startsWith('..') || path.isAbsolute(realRelativePath)) {
throw new Error(`Managed lifecycle path escapes Cursor root: ${lexicalPath}`);
}
return { path: realPath, stat: pathStat };
}
function getManagedOperationSnapshot(state, cursorRoot) {
const snapshot = [];
for (const operation of state.operations) {
if (operation.ownership !== 'managed' || typeof operation.destinationPath !== 'string') {
continue;
}
const resolved = resolveManagedExistingPath(operation.destinationPath, cursorRoot);
if (resolved) {
snapshot.push({ path: resolved.path, isFile: resolved.stat.isFile() });
}
}
return [...new Map(snapshot.map(entry => [entry.path, entry])).values()]
.sort((left, right) => left.path.localeCompare(right.path));
}
function getOperationLedger(state) {
return state.operations.map(operation => ({
kind: operation.kind,
moduleId: operation.moduleId,
sourceRelativePath: operation.sourceRelativePath || null,
destinationPath: operation.destinationPath,
strategy: operation.strategy,
ownership: operation.ownership,
contentSha256: operation.contentSha256 || null,
}));
}
function findDriftCandidate(state, cursorRoot) {
const operation = state.operations.find(candidate => {
if (candidate.kind !== 'copy-file' || typeof candidate.destinationPath !== 'string') {
return false;
}
const resolved = resolveManagedExistingPath(candidate.destinationPath, cursorRoot);
return resolved && resolved.stat.isFile();
});
assert.ok(operation, 'installed state must contain a managed Cursor file that can be drifted');
return resolveManagedExistingPath(operation.destinationPath, cursorRoot).path;
}
function runLifecycle(options) {
assert.ok(fs.existsSync(options.packagePath), `release package does not exist: ${options.packagePath}`);
assertDownloadedArtifact(options.packagePath, process.cwd());
assertHash(hashFile(options.packagePath), options.expectedSha256);
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-packed-lifecycle-'));
const homeDir = path.join(tempRoot, 'home');
const projectDir = path.join(tempRoot, 'project');
fs.mkdirSync(homeDir, { recursive: true });
fs.mkdirSync(projectDir, { recursive: true });
const environment = createLifecycleEnvironment(process.env, homeDir);
try {
installPackage(projectDir, options.packagePath, environment);
const cursorRoot = path.join(projectDir, '.cursor');
const statePath = path.join(cursorRoot, 'ecc-install-state.json');
const sentinelPath = path.join(cursorRoot, 'user-sentinel.txt');
fs.mkdirSync(cursorRoot, { recursive: true });
fs.writeFileSync(sentinelPath, 'keep this user file\n', 'utf8');
const runPublicCli = (publicArgs, commandOptions = {}) => {
const invocation = getNpmExecInvocation(publicArgs, environment);
return runProcess(invocation.command, invocation.args, {
cwd: projectDir,
env: environment,
label: `npm exec -- ${publicArgs.join(' ')}`,
...commandOptions,
});
};
const runCli = (args, commandOptions = {}) => runPublicCli(
['ecc', ...args],
commandOptions
);
const setupHelp = runPublicCli(['ecc-universal', 'setup', '--help']);
assert.match(setupHelp.stdout, /ECC guided setup/);
assert.match(setupHelp.stdout, /ecc setup --mode claude-plugin/);
parseJsonOutput(
runCli(['install', '--profile', 'core', '--target', 'cursor', '--json']),
'initial install'
);
assert.ok(fs.existsSync(statePath), 'initial install must write Cursor install-state');
const initialState = JSON.parse(fs.readFileSync(statePath, 'utf8'));
const initialLedger = getOperationLedger(initialState);
const managedSnapshot = getManagedOperationSnapshot(initialState, cursorRoot);
assert.ok(managedSnapshot.length > 0, 'initial install must create managed Cursor files');
parseJsonOutput(
runCli(['install', '--profile', 'core', '--target', 'cursor', '--json']),
'repeat install'
);
const repeatState = JSON.parse(fs.readFileSync(statePath, 'utf8'));
assert.deepStrictEqual(
getOperationLedger(repeatState),
initialLedger,
'repeat install must preserve the complete ownership ledger'
);
for (const entry of managedSnapshot) {
assert.ok(fs.existsSync(entry.path), `repeat install lost managed path: ${entry.path}`);
}
assert.strictEqual(
fs.readFileSync(sentinelPath, 'utf8'),
'keep this user file\n',
'repeat install must preserve user-owned files'
);
const statusAfterInstall = parseJsonOutput(
runCli(['status', '--json']),
'status after install'
);
assert.strictEqual(statusAfterInstall.installHealth.status, 'healthy');
assert.strictEqual(statusAfterInstall.installHealth.totalCount, 1);
assert.strictEqual(statusAfterInstall.installStateProjection.status, 'ok');
assert.strictEqual(statusAfterInstall.installStateProjection.warningCount, 0);
assert.strictEqual(statusAfterInstall.readiness.status, 'ok');
const healthyBeforeDrift = parseJsonOutput(
runCli(['doctor', '--target', 'cursor', '--json']),
'doctor before drift'
);
assert.strictEqual(healthyBeforeDrift.summary.errorCount, 0);
assert.strictEqual(healthyBeforeDrift.summary.warningCount, 0);
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
const driftPath = findDriftCandidate(state, cursorRoot);
fs.appendFileSync(driftPath, '\nECC_PACKED_LIFECYCLE_DRIFT\n', 'utf8');
const driftedDoctor = parseJsonOutput(
runCli(['doctor', '--target', 'cursor', '--json'], { expectedStatus: 1 }),
'doctor after drift'
);
assert.ok(
driftedDoctor.summary.errorCount + driftedDoctor.summary.warningCount > 0,
'doctor must detect induced managed-file drift'
);
const repair = parseJsonOutput(
runCli(['repair', '--target', 'cursor', '--json']),
'repair'
);
assert.ok(repair.summary.repairedCount > 0, 'repair must restore the drifted managed file');
const healthyAfterRepair = parseJsonOutput(
runCli(['doctor', '--target', 'cursor', '--json']),
'doctor after repair'
);
assert.strictEqual(healthyAfterRepair.summary.errorCount, 0);
assert.strictEqual(healthyAfterRepair.summary.warningCount, 0);
const statusAfterRepair = parseJsonOutput(
runCli(['status', '--json']),
'status after repair'
);
assert.strictEqual(statusAfterRepair.installHealth.status, 'healthy');
assert.strictEqual(statusAfterRepair.installHealth.totalCount, 1);
assert.strictEqual(statusAfterRepair.installStateProjection.status, 'ok');
assert.strictEqual(statusAfterRepair.installStateProjection.warningCount, 0);
assert.strictEqual(statusAfterRepair.readiness.status, 'ok');
parseJsonOutput(
runCli(['uninstall', '--target', 'cursor', '--json']),
'uninstall'
);
assert.ok(!fs.existsSync(statePath), 'uninstall must remove Cursor install-state');
for (const entry of managedSnapshot) {
assert.ok(!fs.existsSync(entry.path), `uninstall left managed path behind: ${entry.path}`);
}
assert.strictEqual(
fs.readFileSync(sentinelPath, 'utf8'),
'keep this user file\n',
'uninstall must preserve user-owned files'
);
const statusAfterUninstall = parseJsonOutput(
runCli(['status', '--json']),
'status after uninstall'
);
assert.strictEqual(statusAfterUninstall.installHealth.status, 'missing');
assert.strictEqual(statusAfterUninstall.installHealth.totalCount, 0);
assert.strictEqual(statusAfterUninstall.installStateProjection.status, 'ok');
assert.strictEqual(statusAfterUninstall.installStateProjection.warningCount, 0);
assert.strictEqual(statusAfterUninstall.readiness.status, 'ok');
return {
packageSha256: options.expectedSha256,
platform: process.platform,
node: process.version,
lifecycle: [
'npm-install',
'public-ecc-universal-setup',
'cursor-install',
'cursor-repeat-install',
'doctor-clean',
'status-installed',
'doctor-drift',
'repair',
'doctor-repaired',
'status-repaired',
'uninstall',
'status-uninstalled',
'sentinel-preserved',
],
};
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
}
function main() {
try {
const report = runLifecycle(parseEnvironment());
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
} catch (error) {
process.stderr.write(`Packed-artifact lifecycle failed: ${error.message}\n`);
process.exitCode = 1;
}
}
module.exports = {
assertDownloadedArtifact,
assertHash,
createLifecycleEnvironment,
getNpmExecInvocation,
hashFile,
parseEnvironment,
runLifecycle,
};
if (require.main === module) {
main();
}
+130
View File
@@ -0,0 +1,130 @@
'use strict';
const assert = require('assert');
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const lifecycle = require('./packed-artifact-lifecycle');
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;
}
}
console.log('\n=== Testing packed-artifact lifecycle runner ===\n');
test('resolves package and hash from explicit environment variables', () => {
const options = lifecycle.parseEnvironment({
ECC_RELEASE_PACKAGE: 'release-artifacts/ecc-universal-2.2.0.tgz',
ECC_RELEASE_SHA256: 'a'.repeat(64),
}, '/workspace');
assert.strictEqual(
options.packagePath,
path.resolve('/workspace', 'release-artifacts/ecc-universal-2.2.0.tgz')
);
assert.strictEqual(options.expectedSha256, 'a'.repeat(64));
});
test('rejects missing, malformed, and non-tgz release inputs', () => {
assert.throws(() => lifecycle.parseEnvironment({}, '/workspace'), /ECC_RELEASE_PACKAGE/);
assert.throws(() => lifecycle.parseEnvironment({
ECC_RELEASE_PACKAGE: 'package.zip',
ECC_RELEASE_SHA256: 'a'.repeat(64),
}, '/workspace'), /\.tgz/);
assert.throws(() => lifecycle.parseEnvironment({
ECC_RELEASE_PACKAGE: 'release-artifacts/ecc-universal-2.2.0.tgz',
ECC_RELEASE_SHA256: 'not-a-hash',
}, '/workspace'), /SHA-256/);
assert.throws(() => lifecycle.parseEnvironment({
ECC_RELEASE_PACKAGE: '../release-artifacts/ecc-universal-2.2.0.tgz',
ECC_RELEASE_SHA256: 'a'.repeat(64),
}, '/workspace'), /release-artifacts/);
assert.throws(() => lifecycle.parseEnvironment({
ECC_RELEASE_PACKAGE: '/tmp/ecc-universal-2.2.0.tgz',
ECC_RELEASE_SHA256: 'a'.repeat(64),
}, '/workspace'), /release-artifacts/);
});
test('hashFile computes a lowercase SHA-256 digest', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-packed-hash-'));
const filePath = path.join(tempDir, 'package.tgz');
try {
fs.writeFileSync(filePath, 'exact packed bytes');
const expected = crypto.createHash('sha256').update('exact packed bytes').digest('hex');
assert.strictEqual(lifecycle.hashFile(filePath), expected);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('assertHash rejects an artifact whose bytes do not match', () => {
assert.throws(
() => lifecycle.assertHash('a'.repeat(64), 'b'.repeat(64)),
/does not match/
);
});
test('lifecycle child processes receive no inherited credentials', () => {
const environment = lifecycle.createLifecycleEnvironment({
PATH: '/tools',
GITHUB_TOKEN: 'github-secret',
NODE_AUTH_TOKEN: 'npm-secret',
ACTIONS_RUNTIME_TOKEN: 'actions-secret',
AWS_SECRET_ACCESS_KEY: 'cloud-secret',
}, '/isolated-home');
assert.strictEqual(environment.PATH, '/tools');
assert.strictEqual(environment.HOME, '/isolated-home');
assert.strictEqual(environment.USERPROFILE, '/isolated-home');
assert.strictEqual(environment.GITHUB_TOKEN, undefined);
assert.strictEqual(environment.NODE_AUTH_TOKEN, undefined);
assert.strictEqual(environment.ACTIONS_RUNTIME_TOKEN, undefined);
assert.strictEqual(environment.AWS_SECRET_ACCESS_KEY, undefined);
});
test('public CLI invocations use npm exec instead of internal package paths', () => {
const invocation = lifecycle.getNpmExecInvocation(
['ecc-universal', 'setup', '--help'],
{ ComSpec: 'C:\\Windows\\System32\\cmd.exe' },
'win32'
);
assert.strictEqual(invocation.command, 'C:\\Windows\\System32\\cmd.exe');
assert.deepStrictEqual(invocation.args, [
'/d',
'/s',
'/c',
'npm exec --offline --yes=false -- ecc-universal setup --help',
]);
const unixInvocation = lifecycle.getNpmExecInvocation(
['ecc', 'doctor', '--target', 'cursor', '--json'],
{},
'linux'
);
assert.strictEqual(unixInvocation.command, 'npm');
assert.deepStrictEqual(
unixInvocation.args.slice(0, 4),
['exec', '--offline', '--yes=false', '--']
);
assert.strictEqual(unixInvocation.args[4], 'ecc');
assert.ok(!unixInvocation.args.some(argument => argument.includes('node_modules')));
});
console.log(`\nPassed: ${passed}`);
console.log(`Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
@@ -0,0 +1,167 @@
'use strict';
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..', '..');
const workflowPaths = [
'.github/workflows/release.yml',
'.github/workflows/reusable-release.yml',
];
const lifecycleRunnerSource = load('tests/ci/packed-artifact-lifecycle.js');
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 load(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8').replace(/\r\n/g, '\n');
}
function jobBlock(source, jobName, nextJobName) {
const startMarker = `\n ${jobName}:\n`;
const start = source.indexOf(startMarker);
assert.ok(start >= 0, `missing ${jobName} job`);
if (!nextJobName) {
return source.slice(start);
}
const end = source.indexOf(`\n ${nextJobName}:\n`, start + startMarker.length);
assert.ok(end > start, `missing ${nextJobName} job after ${jobName}`);
return source.slice(start, end);
}
console.log('\n=== Testing packed-artifact release workflows ===\n');
for (const workflowPath of workflowPaths) {
const source = load(workflowPath);
test(`${workflowPath} packs once and exports the package name and SHA-256`, () => {
assert.strictEqual(
(source.match(/npm pack --json/g) || []).length,
1,
'release workflow must pack exactly once'
);
assert.match(source, /package_sha256:\s*\$\{\{ steps\.pack\.outputs\.package_sha256 \}\}/);
assert.match(source, /createHash\(['"]sha256['"]\)/);
assert.match(source, /package_sha256=['"]? \+ digest/);
assert.match(source, /release_commit:\s*\$\{\{ steps\.source\.outputs\.release_commit \}\}/);
assert.match(source, /release_commit=\$\{RELEASE_COMMIT\}/);
});
test(`${workflowPath} invokes only test files present in the release source`, () => {
const referencedTests = [...source.matchAll(/\bnode (tests\/[A-Za-z0-9_./-]+\.js)\b/g)]
.map(match => match[1]);
assert.ok(referencedTests.length > 0, 'release workflow should run repository tests');
for (const testPath of referencedTests) {
assert.ok(fs.existsSync(path.join(repoRoot, testPath)), `missing workflow test: ${testPath}`);
}
});
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');
const uploadIndex = verify.indexOf('name: Upload release artifacts');
assert.ok(packIndex >= 0, 'missing pack step');
assert.ok(uploadIndex > packIndex, 'artifact upload must happen after pack and hash');
assert.match(verify, /name:\s*ecc-release-artifacts/);
assert.match(verify, /\$\{\{ steps\.pack\.outputs\.package_file \}\}/);
});
test(`${workflowPath} fails retries when npm already has different bytes`, () => {
const verify = jobBlock(source, 'verify', 'lifecycle');
assert.match(verify, /name:\s*Verify existing npm artifact matches candidate/);
assert.match(verify, /if:\s*steps\.npm_publish_state\.outputs\.already_published == 'true'/);
assert.match(verify, /npm view "\$\{PACKAGE_NAME\}@\$\{PACKAGE_VERSION\}" dist\.integrity/);
assert.match(verify, /createHash\(['"]sha512['"]\)/);
assert.match(verify, /Existing npm artifact does not match tested candidate/);
});
test(`${workflowPath} verifies the same tgz on Node 20 across three operating systems`, () => {
const lifecycle = jobBlock(source, 'lifecycle', 'publish');
assert.match(lifecycle, /needs:\s*verify/);
assert.match(lifecycle, /os:\s*\[ubuntu-latest, macos-latest, windows-latest\]/);
assert.match(lifecycle, /runs-on:\s*\$\{\{ matrix\.os \}\}/);
assert.match(lifecycle, /node-version:\s*['"]20\.x['"]/);
assert.match(lifecycle, /uses:\s*actions\/download-artifact@/);
assert.match(lifecycle, /name:\s*ecc-release-artifacts/);
assert.match(lifecycle, /ECC_RELEASE_PACKAGE:\s*release-artifacts\/\$\{\{ needs\.verify\.outputs\.package_file \}\}/);
assert.match(lifecycle, /ECC_RELEASE_SHA256:\s*\$\{\{ needs\.verify\.outputs\.package_sha256 \}\}/);
assert.match(lifecycle, /node tests\/ci\/packed-artifact-lifecycle\.js/);
assert.match(lifecycle, /ref:\s*\$\{\{ needs\.verify\.outputs\.release_commit \}\}/);
assert.doesNotMatch(lifecycle, /\bsecrets\s*:/, 'lifecycle job must not receive secrets');
assert.doesNotMatch(lifecycle, /\$\{\{\s*secrets\./, 'lifecycle job must not reference secrets');
});
test(`${workflowPath} blocks publishing on packed-artifact lifecycle success`, () => {
const publish = jobBlock(source, 'publish');
assert.match(publish, /needs:\s*\[verify, lifecycle\]/);
assert.match(publish, /ECC_RELEASE_PACKAGE:\s*\$\{\{ needs\.verify\.outputs\.package_file \}\}/);
assert.match(publish, /npm publish "\.\/\$\{ECC_RELEASE_PACKAGE\}"/);
assert.match(publish, /name:\s*Verify artifact before publish/);
assert.match(publish, /ECC_RELEASE_SHA256:\s*\$\{\{ needs\.verify\.outputs\.package_sha256 \}\}/);
assert.match(publish, /createHash\(['"]sha256['"]\)/);
assert.match(publish, /ecc-universal-\[0-9A-Za-z\.\+-\]/);
assert.ok(
publish.indexOf('name: Verify artifact before publish')
< publish.indexOf('name: Create GitHub Release'),
'publish must verify the independently downloaded archive before creating the release'
);
});
}
test('reusable release requires its input to resolve through the tag namespace', () => {
const source = load('.github/workflows/reusable-release.yml');
const verify = jobBlock(source, 'verify', 'lifecycle');
assert.match(verify, /ref:\s*refs\/tags\/\$\{\{ inputs\.tag \}\}/);
});
test('pull-request CI packs once and exports the exact installer artifact identity', () => {
const source = load('.github/workflows/ci.yml');
const pack = jobBlock(source, 'pack-installer', 'packed-install-lifecycle');
assert.strictEqual((pack.match(/npm pack --json/g) || []).length, 1);
assert.match(pack, /package_file:\s*\$\{\{ steps\.pack\.outputs\.package_file \}\}/);
assert.match(pack, /package_sha256:\s*\$\{\{ steps\.pack\.outputs\.package_sha256 \}\}/);
assert.match(pack, /createHash\(['"]sha256['"]\)/);
assert.match(pack, /name:\s*ecc-ci-installer-artifact/);
});
test('pull-request CI runs the same packed installer on Linux, macOS, and Windows', () => {
const source = load('.github/workflows/ci.yml');
const lifecycle = jobBlock(source, 'packed-install-lifecycle', 'validate');
assert.match(lifecycle, /needs:\s*pack-installer/);
assert.match(lifecycle, /os:\s*\[ubuntu-latest, macos-latest, windows-latest\]/);
assert.match(lifecycle, /node-version:\s*['"]20\.x['"]/);
assert.match(lifecycle, /name:\s*ecc-ci-installer-artifact/);
assert.match(lifecycle, /ECC_RELEASE_PACKAGE:\s*release-artifacts\/\$\{\{ needs\.pack-installer\.outputs\.package_file \}\}/);
assert.match(lifecycle, /ECC_RELEASE_SHA256:\s*\$\{\{ needs\.pack-installer\.outputs\.package_sha256 \}\}/);
assert.match(lifecycle, /node tests\/ci\/packed-artifact-lifecycle\.js/);
assert.doesNotMatch(lifecycle, /\$\{\{\s*secrets\./);
});
test('packed lifecycle invokes installed public bins, including setup help', () => {
assert.match(lifecycleRunnerSource, /getNpmExecInvocation/);
assert.match(lifecycleRunnerSource, /\['ecc-universal', 'setup', '--help'\]/);
assert.match(lifecycleRunnerSource, /\['ecc', \.\.\.args\]/);
assert.doesNotMatch(lifecycleRunnerSource, /node_modules.*scripts.*ecc\.js/);
});
console.log(`\nPassed: ${passed}`);
console.log(`Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
@@ -0,0 +1,659 @@
/**
* Focused coverage for migrating Antigravity installs from .agent to .agents.
*/
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 {
buildDoctorReport,
discoverInstalledStates,
repairInstalledStates,
uninstallInstalledStates,
} = require('../../scripts/lib/install-lifecycle');
const {
createInstallState,
readInstallState,
writeInstallState,
} = require('../../scripts/lib/install-state');
const REPO_ROOT = path.join(__dirname, '..', '..');
const PACKAGE_VERSION = require('../../package.json').version;
const MANIFEST_VERSION = require('../../manifests/install-modules.json').version;
function test(name, fn) {
try {
fn();
console.log(` \u2713 ${name}`);
return true;
} catch (error) {
console.log(` \u2717 ${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
function digest(content) {
return crypto.createHash('sha256').update(content).digest('hex');
}
function managedCopy(destinationPath, sourceRelativePath, content) {
return {
kind: 'copy-file',
moduleId: 'rules-core',
sourceRelativePath,
destinationPath,
strategy: 'copy-file',
ownership: 'managed',
scaffoldOnly: false,
contentSha256: digest(content),
};
}
function createAntigravityState(targetRoot, installStatePath, operations = []) {
return createInstallState({
adapter: { id: 'antigravity-project', target: 'antigravity', kind: 'project' },
targetRoot,
installStatePath,
request: {
profile: null,
modules: [],
includeComponents: [],
excludeComponents: [],
legacyLanguages: ['typescript'],
legacyMode: true,
},
resolution: {
selectedModules: ['legacy-antigravity-install'],
skippedModules: [],
},
source: {
repoVersion: PACKAGE_VERSION,
repoCommit: 'test-commit',
manifestVersion: MANIFEST_VERSION,
},
operations,
});
}
function seedLegacyState(projectRoot, entries = []) {
const targetRoot = path.join(projectRoot, '.agent');
const installStatePath = path.join(targetRoot, 'ecc-install-state.json');
const operations = entries.map(entry => {
const destinationPath = path.join(targetRoot, entry.relativePath);
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
fs.writeFileSync(destinationPath, entry.recordedContent, 'utf8');
return managedCopy(destinationPath, entry.sourceRelativePath, entry.recordedContent);
});
writeInstallState(
installStatePath,
createAntigravityState(targetRoot, installStatePath, operations)
);
return { targetRoot, installStatePath, operations };
}
function createCanonicalPlan(projectRoot, sourcePath) {
const targetRoot = path.join(projectRoot, '.agents');
const installStatePath = path.join(targetRoot, 'ecc-install-state.json');
const operation = {
kind: 'copy-file',
moduleId: 'rules-core',
sourcePath,
sourceRelativePath: 'rules/common/coding-style.md',
destinationPath: path.join(targetRoot, 'rules', 'coding-style.md'),
strategy: 'copy-file',
ownership: 'managed',
scaffoldOnly: false,
};
return {
mode: 'legacy',
sourceRoot: REPO_ROOT,
target: 'antigravity',
adapter: { id: 'antigravity-project', target: 'antigravity', kind: 'project' },
targetRoot,
installRoot: targetRoot,
installStatePath,
operations: [operation],
warnings: [],
statePreview: createAntigravityState(targetRoot, installStatePath, [operation]),
};
}
function runTests() {
console.log('\n=== Testing Antigravity legacy migration ===\n');
let passed = 0;
let failed = 0;
if (test('writes canonical state before removing unchanged legacy-managed files', () => {
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'antigravity-migrate-'));
try {
const legacy = seedLegacyState(projectRoot, [{
relativePath: 'rules/common-coding-style.md',
sourceRelativePath: 'rules/common/coding-style.md',
recordedContent: fs.readFileSync(
path.join(REPO_ROOT, 'rules', 'common', 'coding-style.md'),
'utf8'
),
}]);
const sourcePath = path.join(projectRoot, 'source.md');
fs.writeFileSync(sourcePath, 'canonical managed\n', 'utf8');
const plan = createCanonicalPlan(projectRoot, sourcePath);
applyInstallPlan(plan, {
writeInstallState(filePath, state) {
assert.ok(fs.existsSync(legacy.installStatePath));
assert.ok(fs.existsSync(legacy.operations[0].destinationPath));
return writeInstallState(filePath, state);
},
});
assert.ok(fs.existsSync(plan.installStatePath));
assert.ok(fs.existsSync(plan.operations[0].destinationPath));
assert.ok(!fs.existsSync(legacy.operations[0].destinationPath));
assert.ok(!fs.existsSync(legacy.installStatePath));
} finally {
fs.rmSync(projectRoot, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('does not clean legacy files when canonical state persistence fails', () => {
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'antigravity-migrate-fail-'));
try {
const legacy = seedLegacyState(projectRoot, [{
relativePath: 'rules/common-coding-style.md',
sourceRelativePath: 'rules/common/coding-style.md',
recordedContent: fs.readFileSync(
path.join(REPO_ROOT, 'rules', 'common', 'coding-style.md'),
'utf8'
),
}]);
const sourcePath = path.join(projectRoot, 'source.md');
fs.writeFileSync(sourcePath, 'canonical managed\n', 'utf8');
assert.throws(
() => applyInstallPlan(createCanonicalPlan(projectRoot, sourcePath), {
writeInstallState() {
throw new Error('simulated canonical state failure');
},
}),
/simulated canonical state failure/
);
assert.ok(fs.existsSync(legacy.operations[0].destinationPath));
assert.ok(fs.existsSync(legacy.installStatePath));
} finally {
fs.rmSync(projectRoot, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('preserves recorded legacy content when the current ECC source has changed', () => {
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'antigravity-source-drift-'));
try {
const legacy = seedLegacyState(projectRoot, [{
relativePath: 'rules/common-coding-style.md',
sourceRelativePath: 'rules/common/coding-style.md',
recordedContent: 'historical ECC content\n',
}]);
const sourcePath = path.join(projectRoot, 'source.md');
fs.writeFileSync(sourcePath, 'canonical managed\n', 'utf8');
const result = applyInstallPlan(createCanonicalPlan(projectRoot, sourcePath));
assert.ok(fs.existsSync(legacy.operations[0].destinationPath));
assert.ok(fs.existsSync(legacy.installStatePath));
assert.ok(result.warnings.some(warning => warning.includes(
'current ECC source differs from the recorded installed content'
)));
} finally {
fs.rmSync(projectRoot, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('preserves drifted and unmanaged legacy files and retains legacy state', () => {
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'antigravity-migrate-partial-'));
try {
const legacy = seedLegacyState(projectRoot, [
{
relativePath: 'rules/common-coding-style.md',
sourceRelativePath: 'rules/common/coding-style.md',
recordedContent: fs.readFileSync(
path.join(REPO_ROOT, 'rules', 'common', 'coding-style.md'),
'utf8'
),
},
{
relativePath: 'rules/common-patterns.md',
sourceRelativePath: 'rules/common/patterns.md',
recordedContent: fs.readFileSync(
path.join(REPO_ROOT, 'rules', 'common', 'patterns.md'),
'utf8'
),
},
]);
fs.writeFileSync(legacy.operations[1].destinationPath, 'customer edit\n', 'utf8');
const unmanagedPath = path.join(legacy.targetRoot, 'customer-note.md');
fs.writeFileSync(unmanagedPath, 'keep me\n', 'utf8');
const sourcePath = path.join(projectRoot, 'source.md');
fs.writeFileSync(sourcePath, 'canonical managed\n', 'utf8');
applyInstallPlan(createCanonicalPlan(projectRoot, sourcePath));
assert.ok(!fs.existsSync(legacy.operations[0].destinationPath));
assert.strictEqual(fs.readFileSync(legacy.operations[1].destinationPath, 'utf8'), 'customer edit\n');
assert.strictEqual(fs.readFileSync(unmanagedPath, 'utf8'), 'keep me\n');
assert.ok(fs.existsSync(legacy.installStatePath));
const remainingLegacyState = readInstallState(legacy.installStatePath);
assert.strictEqual(remainingLegacyState.operations.length, 2);
const report = buildDoctorReport({
repoRoot: REPO_ROOT,
projectRoot,
targets: ['antigravity'],
});
const legacyReport = report.results.find(result => result.legacy);
assert.ok(legacyReport.issues.some(issue => issue.code === 'legacy-antigravity-layout'));
assert.ok(legacyReport.issues.some(issue => issue.code === 'drifted-managed-files'));
assert.ok(legacyReport.issues.some(issue => issue.code === 'missing-managed-files'));
const uninstall = uninstallInstalledStates({ projectRoot, targets: ['antigravity'] });
assert.strictEqual(
fs.readFileSync(legacy.operations[1].destinationPath, 'utf8'),
'customer edit\n'
);
assert.strictEqual(fs.readFileSync(unmanagedPath, 'utf8'), 'keep me\n');
assert.ok(fs.existsSync(legacy.installStatePath));
assert.strictEqual(uninstall.summary.partialCount, 1);
const dryRun = uninstallInstalledStates({
projectRoot,
targets: ['antigravity'],
dryRun: true,
});
const legacyDryRun = dryRun.results.find(result => (
result.installStatePath === legacy.installStatePath
));
assert.deepStrictEqual(legacyDryRun.plannedRemovals, []);
assert.ok(legacyDryRun.retainedPaths.includes(legacy.operations[1].destinationPath));
} finally {
fs.rmSync(projectRoot, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('does not trust a forged legacy digest to delete customer content', () => {
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'antigravity-migrate-forged-'));
try {
const legacy = seedLegacyState(projectRoot, [
{
relativePath: 'rules/common-coding-style.md',
sourceRelativePath: 'rules/common/coding-style.md',
recordedContent: 'customer-owned content\n',
},
{
relativePath: 'customer-note.md',
sourceRelativePath: 'rules/common/patterns.md',
recordedContent: 'customer note\n',
},
{
relativePath: 'README.md',
sourceRelativePath: 'commands/../README.md',
recordedContent: fs.readFileSync(path.join(REPO_ROOT, 'README.md'), 'utf8'),
},
]);
const sourcePath = path.join(projectRoot, 'source.md');
fs.writeFileSync(sourcePath, 'canonical managed\n', 'utf8');
const result = applyInstallPlan(createCanonicalPlan(projectRoot, sourcePath));
assert.strictEqual(
fs.readFileSync(legacy.operations[0].destinationPath, 'utf8'),
'customer-owned content\n'
);
assert.strictEqual(
fs.readFileSync(legacy.operations[1].destinationPath, 'utf8'),
'customer note\n'
);
assert.ok(fs.existsSync(legacy.operations[2].destinationPath));
assert.ok(fs.existsSync(legacy.installStatePath));
assert.ok(result.warnings.some(warning => warning.includes('migration is incomplete')));
} finally {
fs.rmSync(projectRoot, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('warns when digestless legacy files require manual migration', () => {
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'antigravity-migrate-digestless-'));
try {
const legacy = seedLegacyState(projectRoot, [{
relativePath: 'rules/common-coding-style.md',
sourceRelativePath: 'rules/common/coding-style.md',
recordedContent: fs.readFileSync(
path.join(REPO_ROOT, 'rules', 'common', 'coding-style.md'),
'utf8'
),
}]);
const legacyState = readInstallState(legacy.installStatePath);
delete legacyState.operations[0].contentSha256;
writeInstallState(legacy.installStatePath, legacyState);
const sourcePath = path.join(projectRoot, 'source.md');
fs.writeFileSync(sourcePath, 'canonical managed\n', 'utf8');
const result = applyInstallPlan(createCanonicalPlan(projectRoot, sourcePath));
assert.ok(fs.existsSync(legacy.operations[0].destinationPath));
assert.ok(fs.existsSync(legacy.installStatePath));
assert.ok(result.warnings.some(warning => (
warning.includes('Legacy Antigravity migration is incomplete')
&& warning.includes('.agent')
)));
} finally {
fs.rmSync(projectRoot, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('preserves unrelated empty legacy directories after complete cleanup', () => {
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'antigravity-migrate-empty-dir-'));
try {
const legacy = seedLegacyState(projectRoot, [{
relativePath: 'rules/common-coding-style.md',
sourceRelativePath: 'rules/common/coding-style.md',
recordedContent: fs.readFileSync(
path.join(REPO_ROOT, 'rules', 'common', 'coding-style.md'),
'utf8'
),
}]);
const userDirectory = path.join(legacy.targetRoot, 'customer-empty-directory');
fs.mkdirSync(userDirectory);
const sourcePath = path.join(projectRoot, 'source.md');
fs.writeFileSync(sourcePath, 'canonical managed\n', 'utf8');
applyInstallPlan(createCanonicalPlan(projectRoot, sourcePath));
assert.ok(fs.existsSync(userDirectory));
assert.ok(!fs.existsSync(legacy.installStatePath));
} finally {
fs.rmSync(projectRoot, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('list discovery and doctor report both canonical and remaining legacy states', () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'antigravity-home-'));
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'antigravity-discover-'));
try {
const canonicalRoot = path.join(projectRoot, '.agents');
const canonicalStatePath = path.join(canonicalRoot, 'ecc-install-state.json');
writeInstallState(
canonicalStatePath,
createAntigravityState(canonicalRoot, canonicalStatePath)
);
const legacy = seedLegacyState(projectRoot);
const records = discoverInstalledStates({
homeDir,
projectRoot,
targets: ['antigravity'],
}).filter(record => record.exists);
assert.deepStrictEqual(
records.map(record => record.installStatePath).sort(),
[canonicalStatePath, legacy.installStatePath].sort()
);
const report = buildDoctorReport({
repoRoot: REPO_ROOT,
homeDir,
projectRoot,
targets: ['antigravity'],
});
assert.strictEqual(report.results.length, 2);
assert.strictEqual(report.summary.checkedCount, 2);
const legacyReport = report.results.find(result => result.legacy);
assert.ok(legacyReport);
assert.ok(legacyReport.issues.some(issue => (
issue.severity === 'warning'
&& issue.code === 'legacy-antigravity-layout'
)));
fs.rmSync(canonicalStatePath);
const legacyOnlyRecords = discoverInstalledStates({
homeDir,
projectRoot,
targets: ['antigravity'],
}).filter(record => record.exists);
assert.strictEqual(legacyOnlyRecords.length, 1);
assert.strictEqual(legacyOnlyRecords[0].installStatePath, legacy.installStatePath);
assert.strictEqual(legacyOnlyRecords[0].legacy, true);
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
fs.rmSync(projectRoot, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('uninstall discovers and removes both canonical and legacy states', () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'antigravity-home-'));
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'antigravity-uninstall-'));
try {
const canonicalRoot = path.join(projectRoot, '.agents');
const canonicalStatePath = path.join(canonicalRoot, 'ecc-install-state.json');
writeInstallState(
canonicalStatePath,
createAntigravityState(canonicalRoot, canonicalStatePath)
);
const legacy = seedLegacyState(projectRoot);
const dryRun = uninstallInstalledStates({
homeDir,
projectRoot,
targets: ['antigravity'],
dryRun: true,
});
assert.strictEqual(dryRun.results.length, 2);
assert.ok(dryRun.results.some(result => result.installStatePath === canonicalStatePath));
assert.ok(dryRun.results.some(result => result.installStatePath === legacy.installStatePath));
const result = uninstallInstalledStates({
homeDir,
projectRoot,
targets: ['antigravity'],
});
assert.strictEqual(result.summary.uninstalledCount, 2);
assert.ok(!fs.existsSync(canonicalStatePath));
assert.ok(!fs.existsSync(legacy.installStatePath));
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
fs.rmSync(projectRoot, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('does not repair residual legacy state over preserved customer edits', () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'antigravity-home-'));
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'antigravity-repair-'));
try {
const canonicalRoot = path.join(projectRoot, '.agents');
const canonicalStatePath = path.join(canonicalRoot, 'ecc-install-state.json');
writeInstallState(
canonicalStatePath,
createAntigravityState(canonicalRoot, canonicalStatePath)
);
const legacy = seedLegacyState(projectRoot, [{
relativePath: 'rules/coding-style.md',
sourceRelativePath: 'rules/common/coding-style.md',
recordedContent: fs.readFileSync(
path.join(REPO_ROOT, 'rules', 'common', 'coding-style.md'),
'utf8'
),
}]);
fs.writeFileSync(legacy.operations[0].destinationPath, 'customer edit\n', 'utf8');
const result = repairInstalledStates({
repoRoot: REPO_ROOT,
homeDir,
projectRoot,
targets: ['antigravity'],
});
assert.strictEqual(
fs.readFileSync(legacy.operations[0].destinationPath, 'utf8'),
'customer edit\n'
);
assert.ok(!result.results.some(entry => entry.installStatePath === legacy.installStatePath));
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
fs.rmSync(projectRoot, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('does not discover mismatched or symlinked legacy state', () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'antigravity-home-'));
const mismatchedProject = fs.mkdtempSync(path.join(os.tmpdir(), 'antigravity-mismatch-'));
const symlinkProject = fs.mkdtempSync(path.join(os.tmpdir(), 'antigravity-symlink-'));
const externalRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'antigravity-external-'));
try {
const mismatchedRoot = path.join(mismatchedProject, '.agent');
const mismatchedStatePath = path.join(mismatchedRoot, 'ecc-install-state.json');
writeInstallState(mismatchedStatePath, createInstallState({
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
targetRoot: mismatchedRoot,
installStatePath: mismatchedStatePath,
request: {
profile: null,
modules: [],
includeComponents: [],
excludeComponents: [],
legacyLanguages: [],
legacyMode: true,
},
resolution: { selectedModules: [], skippedModules: [] },
source: {
repoVersion: PACKAGE_VERSION,
repoCommit: 'test-commit',
manifestVersion: MANIFEST_VERSION,
},
operations: [],
}));
const mismatchedRecords = discoverInstalledStates({
homeDir,
projectRoot: mismatchedProject,
targets: ['antigravity'],
}).filter(record => record.exists);
assert.strictEqual(mismatchedRecords.length, 0);
if (process.platform !== 'win32') {
const symlinkStatePath = path.join(externalRoot, 'ecc-install-state.json');
const linkedRoot = path.join(symlinkProject, '.agent');
fs.symlinkSync(externalRoot, linkedRoot, 'dir');
writeInstallState(
symlinkStatePath,
createAntigravityState(linkedRoot, path.join(linkedRoot, 'ecc-install-state.json'))
);
const symlinkRecords = discoverInstalledStates({
homeDir,
projectRoot: symlinkProject,
targets: ['antigravity'],
}).filter(record => record.exists);
assert.strictEqual(symlinkRecords.length, 0);
}
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
fs.rmSync(mismatchedProject, { recursive: true, force: true });
fs.rmSync(symlinkProject, { recursive: true, force: true });
fs.rmSync(externalRoot, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('reports corrupt legacy state instead of treating it as absent', () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'antigravity-home-'));
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'antigravity-corrupt-'));
try {
const legacyRoot = path.join(projectRoot, '.agent');
const legacyStatePath = path.join(legacyRoot, 'ecc-install-state.json');
fs.mkdirSync(legacyRoot, { recursive: true });
fs.writeFileSync(legacyStatePath, '{not valid json\n', 'utf8');
const records = discoverInstalledStates({
homeDir,
projectRoot,
targets: ['antigravity'],
}).filter(record => record.exists);
assert.strictEqual(records.length, 1);
assert.strictEqual(records[0].legacy, true);
assert.match(records[0].error, /Unable to inspect legacy Antigravity install-state/);
const sourcePath = path.join(projectRoot, 'source.md');
fs.writeFileSync(sourcePath, 'canonical managed\n', 'utf8');
const result = applyInstallPlan(createCanonicalPlan(projectRoot, sourcePath));
assert.ok(result.warnings.some(warning => warning.includes(
'Unable to inspect legacy Antigravity install-state'
)));
assert.ok(fs.existsSync(legacyStatePath));
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
fs.rmSync(projectRoot, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('doctor and repair compare Antigravity agents using transformed content', () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'antigravity-home-'));
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'antigravity-transform-health-'));
try {
const sourcePath = path.join(REPO_ROOT, 'agents', 'architect.md');
const targetRoot = path.join(projectRoot, '.agents');
const installStatePath = path.join(targetRoot, 'ecc-install-state.json');
const operation = {
kind: 'copy-file',
moduleId: 'agents-core',
sourcePath,
sourceRelativePath: 'agents/architect.md',
destinationPath: path.join(targetRoot, 'agents', 'architect.md'),
strategy: 'copy-file',
ownership: 'managed',
scaffoldOnly: false,
contentTransform: 'antigravity-agent-frontmatter',
};
const plan = {
mode: 'legacy',
target: 'antigravity',
adapter: { id: 'antigravity-project', target: 'antigravity', kind: 'project' },
targetRoot,
installRoot: targetRoot,
installStatePath,
operations: [operation],
warnings: [],
statePreview: createAntigravityState(targetRoot, installStatePath, [operation]),
};
applyInstallPlan(plan);
const installedContent = fs.readFileSync(operation.destinationPath, 'utf8');
assert.notStrictEqual(installedContent, fs.readFileSync(sourcePath, 'utf8'));
const report = buildDoctorReport({
repoRoot: REPO_ROOT,
homeDir,
projectRoot,
targets: ['antigravity'],
});
assert.ok(!report.results[0].issues.some(issue => issue.code === 'drifted-managed-files'));
fs.writeFileSync(operation.destinationPath, 'drifted\n', 'utf8');
const repair = repairInstalledStates({
repoRoot: REPO_ROOT,
homeDir,
projectRoot,
targets: ['antigravity'],
});
assert.strictEqual(repair.results[0].status, 'repaired');
assert.strictEqual(fs.readFileSync(operation.destinationPath, 'utf8'), installedContent);
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
fs.rmSync(projectRoot, { recursive: true, force: true });
}
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
runTests();
+521
View File
@@ -0,0 +1,521 @@
'use strict';
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const {
beginLegacySyncState,
finalizeLegacySyncState,
recordLegacySyncPath,
rollbackLegacyCodexSync,
uninstallLegacyCodexSync,
} = require('../../scripts/lib/codex-legacy-sync');
function tempDir(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
function readStateStatus(statePath) {
return JSON.parse(fs.readFileSync(statePath, 'utf8')).status;
}
function test(name, fn) {
try {
fn();
console.log(`${name}`);
return true;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
function runTests() {
console.log('\n=== Testing Codex legacy sync lifecycle ===\n');
let passed = 0;
let failed = 0;
if (test('manifest uninstall restores previous files, removes owned files, markers, and hooks path', () => {
const homeDir = tempDir('legacy-codex-home-');
const codexHome = path.join(homeDir, '.codex');
const backupDir = path.join(codexHome, 'backups', 'ecc-test');
const configPath = path.join(codexHome, 'config.toml');
const agentsPath = path.join(codexHome, 'AGENTS.md');
const promptPath = path.join(codexHome, 'prompts', 'ecc-plan.md');
const hooksPath = path.join(codexHome, 'git-hooks');
fs.mkdirSync(path.dirname(promptPath), { recursive: true });
fs.mkdirSync(hooksPath, { recursive: true });
fs.writeFileSync(configPath, 'model = "user"\n');
fs.writeFileSync(agentsPath, '# User instructions\n');
fs.writeFileSync(promptPath, '# User prompt with the same name\n');
const statePath = beginLegacySyncState({
codexHome,
backupDir,
previousHooksPath: '/tmp/user-hooks',
installedHooksPath: hooksPath,
});
for (const filePath of [configPath, agentsPath, promptPath, path.join(hooksPath, 'pre-commit')]) {
recordLegacySyncPath({ statePath, filePath });
}
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');
fs.writeFileSync(path.join(hooksPath, 'pre-commit'), '#!/bin/sh\nexit 0\n');
finalizeLegacySyncState({ statePath });
let hooksValue = hooksPath;
const result = uninstallLegacyCodexSync({
codexHome,
getGlobalHooksPath: () => hooksValue,
setGlobalHooksPath: value => { hooksValue = value; },
});
assert.strictEqual(result.status, 'uninstalled');
assert.strictEqual(fs.readFileSync(configPath, 'utf8'), 'model = "user"\n');
assert.strictEqual(fs.readFileSync(agentsPath, 'utf8'), '# User instructions\n');
assert.strictEqual(fs.readFileSync(promptPath, 'utf8'), '# User prompt with the same name\n');
assert.ok(!fs.existsSync(path.join(hooksPath, 'pre-commit')));
assert.strictEqual(hooksValue, '/tmp/user-hooks');
assert.ok(!fs.existsSync(statePath));
fs.rmSync(homeDir, { recursive: true, force: true });
})) passed += 1; else failed += 1;
if (test('dry-run is non-mutating and drifted artifacts are retained', () => {
const homeDir = tempDir('legacy-codex-home-');
const codexHome = path.join(homeDir, '.codex');
const backupDir = path.join(codexHome, 'backups', 'ecc-test');
const promptPath = path.join(codexHome, 'prompts', 'ecc-plan.md');
fs.mkdirSync(path.dirname(promptPath), { recursive: true });
const statePath = beginLegacySyncState({ codexHome, backupDir, previousHooksPath: '' });
recordLegacySyncPath({ statePath, filePath: promptPath });
fs.writeFileSync(promptPath, '# ECC generated prompt\n');
finalizeLegacySyncState({ statePath });
fs.writeFileSync(promptPath, '# customer edit\n');
const dryRun = uninstallLegacyCodexSync({ codexHome, dryRun: true });
assert.strictEqual(dryRun.status, 'planned');
assert.ok(fs.existsSync(statePath));
assert.strictEqual(fs.readFileSync(promptPath, 'utf8'), '# customer edit\n');
const applied = uninstallLegacyCodexSync({ codexHome });
assert.strictEqual(applied.status, 'partial');
assert.deepStrictEqual(applied.retainedPaths, [promptPath]);
assert.ok(fs.existsSync(promptPath));
assert.ok(fs.existsSync(statePath));
fs.rmSync(homeDir, { recursive: true, force: true });
})) passed += 1; else failed += 1;
if (test('uninstall preserves config and AGENTS edits made after legacy sync', () => {
const homeDir = tempDir('legacy-codex-home-');
const codexHome = path.join(homeDir, '.codex');
const configPath = path.join(codexHome, 'config.toml');
const agentsPath = path.join(codexHome, 'AGENTS.md');
fs.mkdirSync(codexHome, { recursive: true });
fs.writeFileSync(configPath, 'model = "user"\n');
fs.writeFileSync(agentsPath, '# User instructions\n');
const statePath = beginLegacySyncState({
codexHome,
backupDir: path.join(codexHome, 'backups', 'ecc-test'),
});
recordLegacySyncPath({ statePath, filePath: configPath });
recordLegacySyncPath({ statePath, filePath: agentsPath });
fs.writeFileSync(configPath, 'model = "user"\napproval_policy = "on-request"\n');
fs.writeFileSync(agentsPath, '# User instructions\n\n<!-- BEGIN ECC -->\n# ECC\n<!-- END ECC -->\n');
finalizeLegacySyncState({ statePath });
fs.appendFileSync(configPath, '# user edit after sync\n');
fs.appendFileSync(agentsPath, '\n# user edit after sync\n');
const result = uninstallLegacyCodexSync({ codexHome });
assert.strictEqual(result.status, 'partial');
assert.ok(fs.readFileSync(configPath, 'utf8').includes('# user edit after sync'));
assert.ok(fs.readFileSync(agentsPath, 'utf8').includes('# user edit after sync'));
assert.ok(result.retainedPaths.includes(configPath));
assert.ok(result.retainedPaths.includes(agentsPath));
fs.rmSync(homeDir, { recursive: true, force: true });
})) passed += 1; else failed += 1;
if (test('pre-manifest cleanup removes only the ECC marker block and preserves all other artifacts', () => {
const homeDir = tempDir('legacy-codex-home-');
const codexHome = path.join(homeDir, '.codex');
const agentsPath = path.join(codexHome, 'AGENTS.md');
const promptPath = path.join(codexHome, 'prompts', 'ecc-plan.md');
fs.mkdirSync(path.dirname(promptPath), { recursive: true });
fs.writeFileSync(
agentsPath,
'# User\n\n<!-- BEGIN ECC -->\n# Old ECC\n<!-- END ECC -->\n\n# More user\n'
);
fs.writeFileSync(promptPath, '# unverifiable legacy prompt\n');
const result = uninstallLegacyCodexSync({ codexHome });
assert.strictEqual(result.status, 'partial');
assert.ok(!fs.readFileSync(agentsPath, 'utf8').includes('BEGIN ECC'));
assert.ok(fs.readFileSync(agentsPath, 'utf8').includes('# User'));
assert.ok(fs.readFileSync(agentsPath, 'utf8').includes('# More user'));
assert.ok(fs.existsSync(promptPath));
assert.ok(result.retainedPaths.includes(promptPath));
fs.rmSync(homeDir, { recursive: true, force: true });
})) passed += 1; else failed += 1;
if (test('pre-manifest cleanup preserves inline, fenced, and symlinked AGENTS markers', () => {
const homeDir = tempDir('legacy-codex-home-');
const codexHome = path.join(homeDir, '.codex');
const agentsPath = path.join(codexHome, 'AGENTS.md');
const outsidePath = path.join(homeDir, 'outside-agents.md');
fs.mkdirSync(codexHome, { recursive: true });
const examples = '# User\nInline <!-- BEGIN ECC --> example <!-- END ECC -->\n```md\n<!-- BEGIN ECC -->\n# Example\n<!-- END ECC -->\n```\n````md\n```md\n<!-- BEGIN ECC -->\n# Nested example\n<!-- END ECC -->\n```\n````\n';
fs.writeFileSync(agentsPath, examples);
const examplesResult = uninstallLegacyCodexSync({ codexHome });
assert.strictEqual(examplesResult.status, 'not-found');
assert.strictEqual(fs.readFileSync(agentsPath, 'utf8'), examples);
fs.writeFileSync(outsidePath, '<!-- BEGIN ECC -->\n# Outside\n<!-- END ECC -->\n');
fs.rmSync(agentsPath);
fs.symlinkSync(outsidePath, agentsPath);
const symlinkResult = uninstallLegacyCodexSync({ codexHome });
assert.strictEqual(symlinkResult.status, 'partial');
assert.ok(symlinkResult.retainedPaths.includes(agentsPath));
assert.strictEqual(fs.readFileSync(outsidePath, 'utf8'), '<!-- BEGIN ECC -->\n# Outside\n<!-- END ECC -->\n');
fs.rmSync(homeDir, { recursive: true, force: true });
})) passed += 1; else failed += 1;
if (test('interrupted sync rollback restores overwritten files and removes newly created files', () => {
const homeDir = tempDir('legacy-codex-home-');
const codexHome = path.join(homeDir, '.codex');
const backupDir = path.join(codexHome, 'backups', 'ecc-test');
const existingPath = path.join(codexHome, 'prompts', 'ecc-plan.md');
const createdPath = path.join(codexHome, 'prompts', 'ecc-review.md');
fs.mkdirSync(path.dirname(existingPath), { recursive: true });
fs.writeFileSync(existingPath, '# User prompt\n', { mode: 0o640 });
const statePath = beginLegacySyncState({
codexHome,
backupDir,
previousHooksPath: '/tmp/user-hooks',
installedHooksPath: path.join(codexHome, 'git-hooks'),
});
recordLegacySyncPath({ statePath, filePath: existingPath });
recordLegacySyncPath({ statePath, filePath: createdPath });
fs.writeFileSync(existingPath, '# Partial ECC write\n');
fs.writeFileSync(createdPath, '# Partial new file\n');
let hooksValue = path.join(codexHome, 'git-hooks');
const result = rollbackLegacyCodexSync({
statePath,
getGlobalHooksPath: () => hooksValue,
setGlobalHooksPath: value => { hooksValue = value; },
});
assert.strictEqual(result.status, 'rolled-back');
assert.strictEqual(fs.readFileSync(existingPath, 'utf8'), '# User prompt\n');
assert.strictEqual(fs.statSync(existingPath).mode & 0o777, 0o640);
assert.ok(!fs.existsSync(createdPath));
assert.strictEqual(hooksValue, '/tmp/user-hooks');
assert.ok(!fs.existsSync(statePath));
fs.rmSync(homeDir, { recursive: true, force: true });
})) passed += 1; else failed += 1;
if (test('recording refuses symlink targets before the sync can write through them', () => {
const homeDir = tempDir('legacy-codex-home-');
const codexHome = path.join(homeDir, '.codex');
const outsidePath = path.join(homeDir, 'outside.md');
const linkedPath = path.join(codexHome, 'prompts', 'ecc-plan.md');
fs.mkdirSync(path.dirname(linkedPath), { recursive: true });
fs.writeFileSync(outsidePath, '# Outside\n');
fs.symlinkSync(outsidePath, linkedPath);
const statePath = beginLegacySyncState({
codexHome,
backupDir: path.join(codexHome, 'backups', 'ecc-test'),
});
assert.throws(
() => recordLegacySyncPath({ statePath, filePath: linkedPath }),
/Refusing to manage non-regular legacy sync path/
);
assert.strictEqual(fs.readFileSync(outsidePath, 'utf8'), '# Outside\n');
fs.rmSync(homeDir, { recursive: true, force: true });
})) passed += 1; else failed += 1;
if (test('recording refuses a symlinked parent directory before any managed write', () => {
const homeDir = tempDir('legacy-codex-home-');
const codexHome = path.join(homeDir, '.codex');
const outsideDir = path.join(homeDir, 'outside');
fs.mkdirSync(codexHome, { recursive: true });
fs.mkdirSync(outsideDir, { recursive: true });
fs.symlinkSync(outsideDir, path.join(codexHome, 'prompts'));
const statePath = beginLegacySyncState({
codexHome,
backupDir: path.join(codexHome, 'backups', 'ecc-test'),
});
assert.throws(
() => recordLegacySyncPath({
statePath,
filePath: path.join(codexHome, 'prompts', 'ecc-plan.md'),
}),
/Refusing to manage legacy sync path through symlinked ancestor/
);
assert.deepStrictEqual(fs.readdirSync(outsideDir), []);
fs.rmSync(homeDir, { recursive: true, force: true });
})) passed += 1; else failed += 1;
if (test('uninstall preserves a managed path replaced by a symlink', () => {
const homeDir = tempDir('legacy-codex-home-');
const codexHome = path.join(homeDir, '.codex');
const promptPath = path.join(codexHome, 'prompts', 'ecc-plan.md');
const outsidePath = path.join(homeDir, 'outside.md');
fs.mkdirSync(path.dirname(promptPath), { recursive: true });
fs.writeFileSync(outsidePath, '# ECC generated prompt\n');
const statePath = beginLegacySyncState({
codexHome,
backupDir: path.join(codexHome, 'backups', 'ecc-test'),
});
recordLegacySyncPath({ statePath, filePath: promptPath });
fs.writeFileSync(promptPath, '# ECC generated prompt\n');
finalizeLegacySyncState({ statePath });
fs.rmSync(promptPath);
fs.symlinkSync(outsidePath, promptPath);
const result = uninstallLegacyCodexSync({ codexHome });
assert.strictEqual(result.status, 'partial');
assert.ok(fs.lstatSync(promptPath).isSymbolicLink());
assert.strictEqual(fs.readFileSync(outsidePath, 'utf8'), '# ECC generated prompt\n');
assert.ok(result.retainedPaths.includes(promptPath));
fs.rmSync(homeDir, { recursive: true, force: true });
})) passed += 1; else failed += 1;
if (test('repeat sync preserves the original pre-ECC baseline through uninstall', () => {
const homeDir = tempDir('legacy-codex-home-');
const codexHome = path.join(homeDir, '.codex');
const promptPath = path.join(codexHome, 'prompts', 'ecc-plan.md');
fs.mkdirSync(path.dirname(promptPath), { recursive: true });
fs.writeFileSync(promptPath, '# Original user prompt\n');
let statePath = beginLegacySyncState({
codexHome,
backupDir: path.join(codexHome, 'backups', 'ecc-first'),
});
recordLegacySyncPath({ statePath, filePath: promptPath });
fs.writeFileSync(promptPath, '# ECC v1\n');
finalizeLegacySyncState({ statePath });
statePath = beginLegacySyncState({
codexHome,
backupDir: path.join(codexHome, 'backups', 'ecc-second'),
});
recordLegacySyncPath({ statePath, filePath: promptPath });
fs.writeFileSync(promptPath, '# ECC v2\n');
finalizeLegacySyncState({ statePath });
const result = uninstallLegacyCodexSync({ codexHome });
assert.strictEqual(result.status, 'uninstalled');
assert.strictEqual(fs.readFileSync(promptPath, 'utf8'), '# Original user prompt\n');
fs.rmSync(homeDir, { recursive: true, force: true });
})) passed += 1; else failed += 1;
if (test('repeat sync refuses drift instead of overwriting a post-install user edit', () => {
const homeDir = tempDir('legacy-codex-home-');
const codexHome = path.join(homeDir, '.codex');
const promptPath = path.join(codexHome, 'prompts', 'ecc-plan.md');
fs.mkdirSync(path.dirname(promptPath), { recursive: true });
const statePath = beginLegacySyncState({
codexHome,
backupDir: path.join(codexHome, 'backups', 'ecc-first'),
});
recordLegacySyncPath({ statePath, filePath: promptPath });
fs.writeFileSync(promptPath, '# ECC v1\n');
finalizeLegacySyncState({ statePath });
fs.appendFileSync(promptPath, '# User edit\n');
assert.throws(
() => beginLegacySyncState({
codexHome,
backupDir: path.join(codexHome, 'backups', 'ecc-second'),
}),
/Refusing to replace modified legacy Codex artifact/
);
assert.ok(fs.readFileSync(promptPath, 'utf8').includes('# User edit'));
assert.strictEqual(readStateStatus(statePath), 'installed');
fs.rmSync(homeDir, { recursive: true, force: true });
})) passed += 1; else failed += 1;
if (test('custom external hooks root is separately trusted and retains original ownership', () => {
const homeDir = tempDir('legacy-codex-home-');
const codexHome = path.join(homeDir, '.codex');
const hooksRoot = path.join(homeDir, 'custom-hooks');
const hookPath = path.join(hooksRoot, 'pre-commit');
fs.mkdirSync(hooksRoot, { recursive: true });
fs.writeFileSync(hookPath, '#!/bin/sh\necho user\n', { mode: 0o700 });
const statePath = beginLegacySyncState({
codexHome,
backupDir: path.join(codexHome, 'backups', 'ecc-test'),
previousHooksPath: hooksRoot,
installedHooksPath: hooksRoot,
});
recordLegacySyncPath({ statePath, filePath: hookPath });
fs.writeFileSync(hookPath, '#!/bin/sh\necho ecc\n', { mode: 0o700 });
finalizeLegacySyncState({ statePath });
const result = uninstallLegacyCodexSync({
codexHome,
getGlobalHooksPath: () => hooksRoot,
setGlobalHooksPath() {},
});
assert.strictEqual(result.status, 'uninstalled');
assert.strictEqual(fs.readFileSync(hookPath, 'utf8'), '#!/bin/sh\necho user\n');
fs.rmSync(homeDir, { recursive: true, force: true });
})) passed += 1; else failed += 1;
if (test('repeat sync rollback remains recoverable after changing custom hooks roots', () => {
const homeDir = tempDir('legacy-codex-home-');
const codexHome = path.join(homeDir, '.codex');
const hooksRootA = path.join(homeDir, 'custom-hooks-a');
const hooksRootB = path.join(homeDir, 'custom-hooks-b');
const hookPathA = path.join(hooksRootA, 'pre-commit');
const hookPathB = path.join(hooksRootB, 'pre-commit');
fs.mkdirSync(hooksRootA, { recursive: true });
fs.mkdirSync(hooksRootB, { recursive: true });
fs.writeFileSync(hookPathA, '#!/bin/sh\necho user-a\n');
let statePath = beginLegacySyncState({
codexHome,
installedHooksPath: hooksRootA,
previousHooksPath: '',
});
recordLegacySyncPath({ statePath, filePath: hookPathA });
fs.writeFileSync(hookPathA, '#!/bin/sh\necho ecc-a\n');
finalizeLegacySyncState({ statePath });
const priorInstalledState = fs.readFileSync(statePath, 'utf8');
statePath = beginLegacySyncState({
codexHome,
installedHooksPath: hooksRootB,
previousHooksPath: hooksRootA,
});
recordLegacySyncPath({ statePath, filePath: hookPathB });
fs.writeFileSync(hookPathA, '#!/bin/sh\necho partial-a\n');
fs.writeFileSync(hookPathB, '#!/bin/sh\necho partial-b\n');
let hooksValue = hooksRootB;
const rollback = rollbackLegacyCodexSync({
statePath,
getGlobalHooksPath: () => hooksValue,
setGlobalHooksPath: value => { hooksValue = value; },
});
assert.strictEqual(rollback.status, 'rolled-back');
assert.strictEqual(fs.readFileSync(hookPathA, 'utf8'), '#!/bin/sh\necho ecc-a\n');
assert.ok(!fs.existsSync(hookPathB));
assert.strictEqual(hooksValue, hooksRootA);
assert.strictEqual(fs.readFileSync(statePath, 'utf8'), priorInstalledState);
fs.rmSync(homeDir, { recursive: true, force: true });
})) passed += 1; else failed += 1;
if (test('repeat sync uninstall restores all custom hooks roots after a root change', () => {
const homeDir = tempDir('legacy-codex-home-');
const codexHome = path.join(homeDir, '.codex');
const hooksRootA = path.join(homeDir, 'custom-hooks-a');
const hooksRootB = path.join(homeDir, 'custom-hooks-b');
const hookPathA = path.join(hooksRootA, 'pre-commit');
const hookPathB = path.join(hooksRootB, 'pre-commit');
fs.mkdirSync(hooksRootA, { recursive: true });
fs.mkdirSync(hooksRootB, { recursive: true });
fs.writeFileSync(hookPathA, '#!/bin/sh\necho user-a\n');
let statePath = beginLegacySyncState({
codexHome,
installedHooksPath: hooksRootA,
previousHooksPath: '',
});
recordLegacySyncPath({ statePath, filePath: hookPathA });
fs.writeFileSync(hookPathA, '#!/bin/sh\necho ecc-a\n');
finalizeLegacySyncState({ statePath });
statePath = beginLegacySyncState({
codexHome,
installedHooksPath: hooksRootB,
previousHooksPath: hooksRootA,
});
recordLegacySyncPath({ statePath, filePath: hookPathB });
fs.writeFileSync(hookPathB, '#!/bin/sh\necho ecc-b\n');
finalizeLegacySyncState({ statePath });
let hooksValue = hooksRootB;
const uninstall = uninstallLegacyCodexSync({
codexHome,
getGlobalHooksPath: () => hooksValue,
setGlobalHooksPath: value => { hooksValue = value; },
});
assert.strictEqual(uninstall.status, 'uninstalled');
assert.strictEqual(fs.readFileSync(hookPathA, 'utf8'), '#!/bin/sh\necho user-a\n');
assert.ok(!fs.existsSync(hookPathB));
assert.strictEqual(hooksValue, '');
assert.ok(!fs.existsSync(statePath));
fs.rmSync(homeDir, { recursive: true, force: true });
})) passed += 1; else failed += 1;
if (test('rollback preserves a managed path replaced by a dangling symlink', () => {
const homeDir = tempDir('legacy-codex-home-');
const codexHome = path.join(homeDir, '.codex');
const promptPath = path.join(codexHome, 'prompts', 'ecc-plan.md');
const outsidePath = path.join(homeDir, 'missing-outside.md');
fs.mkdirSync(path.dirname(promptPath), { recursive: true });
fs.writeFileSync(promptPath, '# Original user prompt\n');
const statePath = beginLegacySyncState({
codexHome,
backupDir: path.join(codexHome, 'backups', 'ecc-test'),
});
recordLegacySyncPath({ statePath, filePath: promptPath });
fs.rmSync(promptPath);
fs.symlinkSync(outsidePath, promptPath);
const result = rollbackLegacyCodexSync({ statePath });
assert.strictEqual(result.status, 'partial');
assert.ok(result.retainedPaths.includes(promptPath));
assert.ok(fs.lstatSync(promptPath).isSymbolicLink());
assert.ok(!fs.existsSync(outsidePath));
fs.rmSync(homeDir, { recursive: true, force: true });
})) passed += 1; else failed += 1;
if (test('failed repeat sync restores the prior installed ownership manifest', () => {
const homeDir = tempDir('legacy-codex-home-');
const codexHome = path.join(homeDir, '.codex');
const promptPath = path.join(codexHome, 'prompts', 'ecc-plan.md');
fs.mkdirSync(path.dirname(promptPath), { recursive: true });
fs.writeFileSync(promptPath, '# Original user prompt\n');
let statePath = beginLegacySyncState({
codexHome,
backupDir: path.join(codexHome, 'backups', 'ecc-first'),
});
recordLegacySyncPath({ statePath, filePath: promptPath });
fs.writeFileSync(promptPath, '# ECC v1\n');
finalizeLegacySyncState({ statePath });
const priorInstalledState = fs.readFileSync(statePath, 'utf8');
statePath = beginLegacySyncState({
codexHome,
backupDir: path.join(codexHome, 'backups', 'ecc-second'),
});
fs.writeFileSync(promptPath, '# Partial ECC v2\n');
const rollback = rollbackLegacyCodexSync({ statePath });
assert.strictEqual(rollback.status, 'rolled-back');
assert.strictEqual(fs.readFileSync(promptPath, 'utf8'), '# ECC v1\n');
assert.strictEqual(fs.readFileSync(statePath, 'utf8'), priorInstalledState);
const uninstall = uninstallLegacyCodexSync({ codexHome });
assert.strictEqual(uninstall.status, 'uninstalled');
assert.strictEqual(fs.readFileSync(promptPath, 'utf8'), '# Original user prompt\n');
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);
}
runTests();
+2 -1
View File
@@ -88,7 +88,7 @@ function runTests() {
if (test('keeps every advanced target attached to its registered root and scope', () => {
const expected = {
cursor: ['project', './.cursor'],
antigravity: ['project', './.agent'],
antigravity: ['project', './.agents'],
gemini: ['project', './.gemini'],
opencode: ['home', '~/.opencode'],
codebuddy: ['project', './.codebuddy'],
@@ -103,6 +103,7 @@ function runTests() {
const harness = getHarnessCapability(id);
assert.strictEqual(harness.guidedReady, false, id);
assert.strictEqual(harness.availability, 'advanced', id);
assert.strictEqual(harness.destination, root, id);
assert.deepStrictEqual(harness.scopes, [
{ id: scopeId, targetId: id, root },
], id);
+25 -7
View File
@@ -54,6 +54,10 @@ function writeLegacySourceFixture(root) {
writeFile(root, path.join('rules', 'common', 'nested', 'shared.md'), '# Shared\n');
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', 'stray.pyc'), 'ignored\n');
writeFile(root, path.join('rules', 'common', 'stray.pyo'), 'ignored\n');
writeFile(root, path.join('rules', 'common', 'stray.pyd'), 'ignored\n');
writeFile(root, path.join('rules', 'typescript', 'testing.md'), '# TS\n');
writeFile(root, path.join('rules', 'python', 'testing.md'), '# Python\n');
@@ -111,6 +115,10 @@ function writeManifestSourceFixture(root) {
writeFile(root, path.join('src', 'nested', 'feature.js'), 'console.log("feature");\n');
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', 'stray.pyc'), 'ignored\n');
writeFile(root, path.join('src', 'stray.pyo'), 'ignored\n');
writeFile(root, path.join('src', 'stray.pyd'), 'ignored\n');
writeFile(root, path.join('src', 'nested', 'ecc-install-state.json'), '{}\n');
writeFile(root, path.join('rules', 'common', 'coding-style.md'), '# Common\n');
writeFile(root, path.join('skills', 'demo', 'SKILL.md'), '# Demo\n');
@@ -192,6 +200,8 @@ function runTests() {
assert.ok(operationFor(plan, path.join('custom-rules', 'typescript', 'testing.md')));
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 => /\.(?:pyc|pyo|pyd)$/.test(operation.sourceRelativePath)));
assert.deepStrictEqual(plan.statePreview.request.legacyLanguages, ['typescript', 'missing-lang', '../bad']);
assert.strictEqual(plan.statePreview.request.legacyMode, true);
assert.strictEqual(plan.statePreview.source.repoVersion, '9.8.7');
@@ -297,7 +307,7 @@ function runTests() {
const homeDir = createTempDir('install-executor-home-');
try {
writeLegacySourceFixture(sourceRoot);
writeFile(projectRoot, path.join('.agent', 'rules', 'existing.md'), '# Existing\n');
writeFile(projectRoot, path.join('.agents', 'rules', 'existing.md'), '# Existing\n');
const plan = createLegacyInstallPlan({
sourceRoot,
@@ -307,15 +317,21 @@ function runTests() {
languages: ['typescript', 'missing-lang', 'bad/name'],
});
assert.strictEqual(plan.installRoot, path.join(projectRoot, '.agent'));
assert.strictEqual(plan.installRoot, path.join(projectRoot, '.agents'));
assert.ok(plan.warnings.some(warning => warning.includes('files may be overwritten')));
assert.ok(plan.warnings.some(warning => warning.includes("rules/missing-lang/ does not exist")));
assert.ok(plan.warnings.some(warning => warning.includes("Invalid language name 'bad/name'")));
assert.ok(operationFor(plan, path.join('.agent', 'rules', 'common-coding-style.md')));
assert.ok(operationFor(plan, path.join('.agent', 'rules', 'typescript-testing.md')));
assert.ok(operationFor(plan, path.join('.agent', 'workflows', 'plan.md')));
assert.ok(operationFor(plan, path.join('.agent', 'skills', 'architect.md')));
assert.ok(operationFor(plan, path.join('.agent', 'skills', 'demo', 'SKILL.md')));
assert.ok(operationFor(plan, path.join('.agents', 'rules', 'common-coding-style.md')));
assert.ok(operationFor(plan, path.join('.agents', 'rules', 'typescript-testing.md')));
assert.ok(operationFor(plan, path.join('.agents', 'workflows', 'plan.md')));
const agentOperation = plan.operations.find(operation => (
operation.destinationPath.endsWith(path.join('.agents', 'agents', 'architect.md'))
));
assert.ok(agentOperation);
assert.strictEqual(agentOperation.contentTransform, 'antigravity-agent-frontmatter');
assert.ok(plan.operations.some(operation => (
operation.destinationPath.endsWith(path.join('.agents', 'skills', 'demo', 'SKILL.md'))
)));
assert.strictEqual(plan.statePreview.target.id, 'antigravity-project');
} finally {
cleanup(sourceRoot);
@@ -354,6 +370,8 @@ function runTests() {
assert.ok(!normalizedSources.includes('src/nested/ecc-install-state.json'));
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 => /\.(?:pyc|pyo|pyd)$/.test(source)));
assert.ok(plan.operations.some(operation => (
operation.sourceRelativePath === path.join('.claude-plugin', 'plugin.json')
&& operation.destinationPath === path.join(homeDir, '.claude', 'plugin.json')
+189
View File
@@ -3,6 +3,7 @@
*/
const assert = require('assert');
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
@@ -14,6 +15,7 @@ const {
repairInstalledStates,
uninstallInstalledStates,
} = require('../../scripts/lib/install-lifecycle');
const { applyInstallPlan } = require('../../scripts/lib/install/apply');
const { getInstallTargetAdapter } = require('../../scripts/lib/install-targets/registry');
const {
createInstallState,
@@ -635,6 +637,59 @@ function runTests() {
}
})) passed++; else failed++;
if (test('no-op repair preserves recorded source metadata until upgraded bytes are installed', () => {
const homeDir = createTempDir('install-lifecycle-home-');
const projectRoot = createTempDir('install-lifecycle-project-');
try {
const targetRoot = path.join(projectRoot, '.cursor');
const destinationPath = path.join(targetRoot, 'rules', 'coding-style.md');
const sourcePath = path.join(REPO_ROOT, 'rules', 'common', 'coding-style.md');
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
fs.copyFileSync(sourcePath, destinationPath);
const contentSha256 = crypto.createHash('sha256')
.update(fs.readFileSync(destinationPath))
.digest('hex');
const fixture = writeCursorState(projectRoot, {
source: {
repoVersion: '1.0.0',
repoCommit: 'old-commit',
manifestVersion: CURRENT_MANIFEST_VERSION,
},
operations: [
managedOperation('copy-file', destinationPath, {
sourceRelativePath: 'rules/common/coding-style.md',
strategy: 'copy-file',
contentSha256,
}),
],
});
const repair = repairInstalledStates({
repoRoot: REPO_ROOT,
homeDir,
projectRoot,
targets: ['cursor'],
});
const stateAfterRepair = readInstallState(fixture.installStatePath);
const doctor = buildDoctorReport({
repoRoot: REPO_ROOT,
homeDir,
projectRoot,
targets: ['cursor'],
});
assert.strictEqual(repair.results[0].status, 'ok');
assert.strictEqual(repair.results[0].stateRefreshed, true);
assert.strictEqual(stateAfterRepair.source.repoVersion, '1.0.0');
assert.strictEqual(stateAfterRepair.source.manifestVersion, CURRENT_MANIFEST_VERSION);
assert.ok(doctor.results[0].issues.some(issue => issue.code === 'repo-version-mismatch'));
} finally {
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
if (test('Claude repair and dry-run preserve user-owned flat skills during legacy migration', () => {
const homeDir = createTempDir('install-lifecycle-home-');
const projectRoot = createTempDir('install-lifecycle-project-');
@@ -1460,6 +1515,140 @@ function runTests() {
}
})) passed++; else failed++;
if (test('doctor reproduces install-time link rewrites for managed copy files', () => {
const homeDir = createTempDir('install-lifecycle-home-');
const projectRoot = createTempDir('install-lifecycle-project-');
try {
const targetRoot = path.join(projectRoot, '.agents');
const statePath = path.join(targetRoot, 'ecc-install-state.json');
const operations = ['code-review.md', 'testing.md'].map(fileName => ({
kind: 'copy-file',
moduleId: 'rules-core',
sourcePath: path.join(REPO_ROOT, 'rules', 'common', fileName),
sourceRelativePath: path.join('rules', 'common', fileName),
destinationPath: path.join(targetRoot, 'rules', `common-${fileName}`),
strategy: 'flatten-copy',
ownership: 'managed',
scaffoldOnly: false,
}));
const state = createInstallState({
adapter: { id: 'antigravity-project', target: 'antigravity', kind: 'project' },
targetRoot,
installStatePath: statePath,
request: {
profile: null,
modules: [],
legacyLanguages: ['typescript'],
legacyMode: true,
},
resolution: {
selectedModules: ['rules-core'],
skippedModules: [],
},
operations,
source: {
repoVersion: CURRENT_PACKAGE_VERSION,
repoCommit: 'abc123',
manifestVersion: CURRENT_MANIFEST_VERSION,
},
});
applyInstallPlan({
mode: 'legacy',
target: 'antigravity',
adapter: { id: 'antigravity-project', target: 'antigravity', kind: 'project' },
targetRoot,
installRoot: targetRoot,
installStatePath: statePath,
operations,
warnings: [],
statePreview: state,
});
const report = buildDoctorReport({
repoRoot: REPO_ROOT,
homeDir,
projectRoot,
targets: ['antigravity'],
});
assert.ok(!report.results[0].issues.some(issue => issue.code === 'drifted-managed-files'));
fs.writeFileSync(operations[0].destinationPath, 'customer edit\n');
const driftedReport = buildDoctorReport({
repoRoot: REPO_ROOT,
homeDir,
projectRoot,
targets: ['antigravity'],
});
assert.ok(driftedReport.results[0].issues.some(issue => issue.code === 'drifted-managed-files'));
const repair = repairInstalledStates({
repoRoot: REPO_ROOT,
homeDir,
projectRoot,
targets: ['antigravity'],
});
assert.strictEqual(repair.results[0].status, 'repaired');
assert.ok(
fs.readFileSync(operations[0].destinationPath, 'utf8').includes('(common-testing.md)')
);
const repairedState = readInstallState(statePath);
assert.match(repairedState.operations[0].contentSha256, /^[a-f0-9]{64}$/);
const repairedReport = buildDoctorReport({
repoRoot: REPO_ROOT,
homeDir,
projectRoot,
targets: ['antigravity'],
});
assert.ok(!repairedReport.results[0].issues.some(issue => issue.code === 'drifted-managed-files'));
} finally {
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
if (test('doctor trusts a recorded installed digest before comparing a newer source tree', () => {
const homeDir = createTempDir('install-lifecycle-home-');
const projectRoot = createTempDir('install-lifecycle-project-');
try {
const targetRoot = path.join(projectRoot, '.cursor');
const destinationPath = path.join(targetRoot, 'rules', 'coding-style.md');
const installedContent = 'installed from an older verified release\n';
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
fs.writeFileSync(destinationPath, installedContent);
const contentSha256 = crypto.createHash('sha256').update(installedContent).digest('hex');
const installStatePath = path.join(targetRoot, 'ecc-install-state.json');
writeState(installStatePath, createCursorStateOptions(projectRoot, {
operations: [managedOperation('copy-file', destinationPath, {
sourceRelativePath: path.join('rules', 'common', 'coding-style.md'),
contentSha256,
})],
}));
const report = buildDoctorReport({
repoRoot: REPO_ROOT,
homeDir,
projectRoot,
targets: ['cursor'],
});
assert.ok(!report.results[0].issues.some(issue => issue.code === 'drifted-managed-files'));
fs.writeFileSync(destinationPath, 'customer edit\n');
const drifted = buildDoctorReport({
repoRoot: REPO_ROOT,
homeDir,
projectRoot,
targets: ['cursor'],
});
assert.ok(drifted.results[0].issues.some(issue => issue.code === 'drifted-managed-files'));
} finally {
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
if (test('doctor reports manifest resolution drift for non-legacy installs', () => {
const homeDir = createTempDir('install-lifecycle-home-');
const projectRoot = createTempDir('install-lifecycle-project-');
+12 -5
View File
@@ -265,7 +265,7 @@ function runTests() {
assert.ok(!plan.skippedModuleIds.includes('platform-configs'));
assert.ok(!plan.skippedModuleIds.includes('workflow-quality'));
assert.strictEqual(plan.targetAdapterId, 'antigravity-project');
assert.strictEqual(plan.targetRoot, path.join(projectRoot, '.agent'));
assert.strictEqual(plan.targetRoot, path.join(projectRoot, '.agents'));
})) passed++; else failed++;
if (test('resolves minimal profile without the hook runtime', () => {
@@ -529,10 +529,14 @@ function runTests() {
if (test('keeps antigravity legacy compatibility selections target-safe', () => {
const selection = resolveLegacyCompatibilitySelection({
target: 'antigravity',
legacyLanguages: ['typescript'],
legacyLanguages: ['c', 'go', 'kotlin'],
});
assert.deepStrictEqual(selection.moduleIds, ['rules-core', 'agents-core', 'commands-core']);
assert.deepStrictEqual(selection.ruleLanguages, ['cpp', 'golang', 'kotlin']);
assert.deepStrictEqual(
selection.moduleIds,
['rules-core', 'agents-core', 'commands-core', 'skill-unified-memory', 'workflow-quality']
);
})) passed++; else failed++;
if (test('rejects unknown legacy compatibility languages', () => {
@@ -875,8 +879,11 @@ function runTests() {
'Unsupported antigravity paths should be filtered from planned operations'
);
assert.ok(
plan.operations.every(operation => operation.sourceRelativePath !== 'skills/example'),
'ECC skills should be filtered: antigravity .agent/skills holds ECC agents'
plan.operations.some(operation => (
operation.sourceRelativePath === 'skills/example'
&& operation.destinationPath === path.join('/workspace/app', '.agents', 'skills', 'example')
)),
'Canonical skill sources should be installed into native Antigravity skills'
);
assert.ok(
plan.operations.some(operation => operation.sourceRelativePath === 'commands/example'),
+347
View File
@@ -0,0 +1,347 @@
/**
* Regression tests for projecting canonical JSON install state into the
* SQLite status store (#2750).
*/
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const crypto = require('crypto');
const {
buildInstallStateStoreRecord,
createStateStore,
reconcileInstallStateProjections,
} = require('../../scripts/lib/state-store');
const {
projectCanonicalInstallState,
reconcileCanonicalInstallStates,
} = require('../../scripts/lib/install-state-store-sync');
const { createInstallState, writeInstallState } = require('../../scripts/lib/install-state');
const STATUS_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'status.js');
async function test(name, fn) {
try {
await fn();
console.log(` \u2713 ${name}`);
return true;
} catch (error) {
console.log(` \u2717 ${name}`);
console.log(` Error: ${error.stack || error.message}`);
return false;
}
}
function createTempDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-install-projection-'));
}
function createState(options = {}) {
const targetRoot = options.targetRoot;
const installStatePath = options.installStatePath || path.join(targetRoot, 'ecc-install-state.json');
return createInstallState({
adapter: {
id: options.targetId || 'claude-home',
target: options.target || 'claude',
kind: options.kind || 'home',
},
targetRoot,
installStatePath,
request: {
profile: 'developer',
modules: ['rules-core'],
includeComponents: [],
excludeComponents: [],
legacyLanguages: [],
legacyMode: false,
},
resolution: {
selectedModules: ['rules-core'],
skippedModules: [],
},
operations: Array.isArray(options.operations) ? options.operations : [],
source: {
repoVersion: '2.2.0',
repoCommit: 'abc123',
manifestVersion: 1,
},
installedAt: '2026-08-13T12:00:00.000Z',
});
}
function discoveryRecord(state, options = {}) {
const targetId = options.targetId || state.target.id;
const targetRoot = options.targetRoot || state.target.root;
return {
adapter: {
id: targetId,
target: options.target || state.target.target || 'claude',
kind: options.kind || state.target.kind || 'home',
},
targetRoot,
installStatePath: options.installStatePath || state.target.installStatePath,
exists: options.exists !== undefined ? options.exists : true,
state: options.state !== undefined ? options.state : state,
error: options.error || null,
legacy: false,
};
}
async function runTests() {
console.log('\n=== Testing install-state projection ===\n');
let passed = 0;
let failed = 0;
if (await test('maps canonical install state to the status-store projection', () => {
const state = createState({ targetRoot: '/tmp/home/.claude' });
assert.deepStrictEqual(buildInstallStateStoreRecord(state), {
targetId: 'claude-home',
targetRoot: '/tmp/home/.claude',
profile: 'developer',
modules: ['rules-core'],
operations: [],
installedAt: '2026-08-13T12:00:00.000Z',
sourceVersion: '2.2.0',
});
})) passed += 1; else failed += 1;
if (await test('reconciles present and absent discoverable targets without deleting other scopes', async () => {
const store = await createStateStore({ dbPath: ':memory:' });
try {
const currentRoot = '/tmp/current/.claude';
const absentRoot = '/tmp/current/.codex';
const otherRoot = '/tmp/other/.claude';
store.upsertInstallState({
targetId: 'codex-home',
targetRoot: absentRoot,
installedAt: '2026-08-01T00:00:00.000Z',
sourceVersion: '2.1.0',
});
store.upsertInstallState({
targetId: 'claude-home',
targetRoot: otherRoot,
installedAt: '2026-08-01T00:00:00.000Z',
sourceVersion: '2.1.0',
});
const state = createState({ targetRoot: currentRoot });
const result = reconcileInstallStateProjections(store, [
discoveryRecord(state),
{
adapter: { id: 'codex-home', target: 'codex', kind: 'home' },
targetRoot: absentRoot,
installStatePath: path.join(absentRoot, 'ecc-install-state.json'),
exists: false,
state: null,
error: null,
legacy: false,
},
]);
const installations = store.getStatus().installHealth.installations;
assert.strictEqual(result.status, 'ok');
assert.strictEqual(result.projectedCount, 1);
assert.strictEqual(result.removedCount, 1);
assert.deepStrictEqual(
installations.map(row => [row.targetId, row.targetRoot]).sort(),
[
['claude-home', currentRoot],
['claude-home', otherRoot],
].sort()
);
} finally {
store.close();
}
})) passed += 1; else failed += 1;
if (await test('removes only the discoverable stale row when canonical state is invalid', async () => {
const store = await createStateStore({ dbPath: ':memory:' });
try {
const targetRoot = '/tmp/current/.claude';
store.upsertInstallState({
targetId: 'claude-home',
targetRoot,
installedAt: '2026-08-01T00:00:00.000Z',
sourceVersion: '2.1.0',
});
const state = createState({ targetRoot });
const result = reconcileInstallStateProjections(store, [
discoveryRecord(state, {
state: null,
error: 'Invalid install-state',
}),
]);
assert.strictEqual(result.status, 'warning');
assert.strictEqual(result.removedCount, 1);
assert.strictEqual(result.warningCount, 1);
assert.strictEqual(result.warnings[0].code, 'invalid-install-state');
assert.strictEqual(store.getStatus().installHealth.totalCount, 0);
} finally {
store.close();
}
})) passed += 1; else failed += 1;
if (await test('returns projection failures as warnings and continues reconciling', () => {
const first = createState({ targetRoot: '/tmp/one/.claude' });
const second = createState({ targetRoot: '/tmp/two/.claude' });
const projected = [];
const store = {
upsertInstallState(record) {
if (record.targetRoot.includes('/one/')) {
throw new Error('database is read-only');
}
projected.push(record.targetRoot);
},
deleteInstallState() {
return false;
},
};
const result = reconcileInstallStateProjections(store, [
discoveryRecord(first),
discoveryRecord(second),
]);
assert.strictEqual(result.status, 'warning');
assert.strictEqual(result.projectedCount, 1);
assert.strictEqual(result.warningCount, 1);
assert.strictEqual(result.warnings[0].code, 'projection-write-failed');
assert.deepStrictEqual(projected, ['/tmp/two/.claude']);
})) passed += 1; else failed += 1;
if (await test('status discovers canonical JSON state before querying install health', async () => {
const tempDir = createTempDir();
const homeDir = path.join(tempDir, 'home');
const projectDir = path.join(tempDir, 'project');
const targetRoot = path.join(homeDir, '.claude');
const installStatePath = path.join(targetRoot, 'ecc', 'install-state.json');
const dbPath = path.join(tempDir, 'state.db');
fs.mkdirSync(projectDir, { recursive: true });
writeInstallState(installStatePath, createState({ targetRoot, installStatePath }));
try {
const result = spawnSync(process.execPath, [STATUS_SCRIPT, '--db', dbPath, '--json'], {
cwd: projectDir,
encoding: 'utf8',
env: { ...process.env, HOME: homeDir },
});
assert.strictEqual(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.strictEqual(payload.installHealth.status, 'healthy');
assert.strictEqual(payload.installHealth.totalCount, 1);
assert.strictEqual(payload.installHealth.installations[0].targetRoot, targetRoot);
assert.strictEqual(payload.installStateProjection.status, 'ok');
assert.strictEqual(payload.installStateProjection.projectedCount, 1);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
})) passed += 1; else failed += 1;
if (await test('status reports warning health when a canonical managed file is drifted or missing', async () => {
const tempDir = createTempDir();
const homeDir = path.join(tempDir, 'home');
const projectDir = path.join(tempDir, 'project');
const targetRoot = path.join(homeDir, '.claude');
const installStatePath = path.join(targetRoot, 'ecc', 'install-state.json');
const destinationPath = path.join(targetRoot, 'managed-package.json');
const sourcePath = path.join(__dirname, '..', '..', 'package.json');
const sourceRelativePath = 'package.json';
const dbPath = path.join(tempDir, 'state.db');
const contentSha256 = crypto.createHash('sha256').update(fs.readFileSync(sourcePath)).digest('hex');
fs.mkdirSync(projectDir, { recursive: true });
fs.mkdirSync(targetRoot, { recursive: true });
fs.writeFileSync(destinationPath, 'drifted content');
writeInstallState(installStatePath, createState({
targetRoot,
installStatePath,
operations: [{
kind: 'copy-file',
moduleId: 'rules-core',
sourceRelativePath,
destinationPath,
strategy: 'preserve-relative-path',
ownership: 'managed',
scaffoldOnly: false,
contentSha256,
}],
}));
try {
const result = spawnSync(process.execPath, [STATUS_SCRIPT, '--db', dbPath, '--json'], {
cwd: projectDir,
encoding: 'utf8',
env: { ...process.env, HOME: homeDir },
});
assert.strictEqual(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.strictEqual(payload.installHealth.status, 'warning');
assert.strictEqual(payload.installHealth.healthyCount, 0);
assert.strictEqual(payload.installHealth.warningCount, 1);
assert.strictEqual(payload.installHealth.installations[0].status, 'warning');
assert.ok(payload.installHealth.installations[0].issues.some(
issue => issue.code === 'drifted-managed-files'
));
assert.strictEqual(payload.readiness.status, 'attention');
fs.unlinkSync(destinationPath);
const missingResult = spawnSync(process.execPath, [STATUS_SCRIPT, '--db', dbPath, '--json'], {
cwd: projectDir,
encoding: 'utf8',
env: { ...process.env, HOME: homeDir },
});
assert.strictEqual(missingResult.status, 0, missingResult.stderr);
const missingPayload = JSON.parse(missingResult.stdout);
assert.strictEqual(missingPayload.installHealth.status, 'warning');
assert.ok(missingPayload.installHealth.installations[0].issues.some(
issue => issue.code === 'missing-managed-files'
));
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
})) passed += 1; else failed += 1;
if (await test('command-boundary sync projects and removes canonical install state', async () => {
const tempDir = createTempDir();
const homeDir = path.join(tempDir, 'home');
const projectDir = path.join(tempDir, 'project');
const dbPath = path.join(tempDir, 'state.db');
const targetRoot = path.join(homeDir, '.claude');
const installStatePath = path.join(targetRoot, 'ecc', 'install-state.json');
fs.mkdirSync(projectDir, { recursive: true });
const state = createState({ targetRoot, installStatePath });
try {
const projected = await projectCanonicalInstallState(state, { dbPath, homeDir });
assert.strictEqual(projected.status, 'projected');
const store = await createStateStore({ dbPath });
assert.strictEqual(store.getStatus().installHealth.totalCount, 1);
store.close();
const reconciled = await reconcileCanonicalInstallStates({ dbPath, homeDir, projectRoot: projectDir });
assert.strictEqual(reconciled.status, 'ok');
assert.strictEqual(reconciled.removedCount, 1);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
})) passed += 1; else failed += 1;
if (await test('command-boundary sync isolates database failures as warnings', async () => {
const state = createState({ targetRoot: '/tmp/home/.claude' });
const result = await projectCanonicalInstallState(state, {
createStore: async () => { throw new Error('database unavailable'); },
});
assert.strictEqual(result.status, 'warning');
assert.strictEqual(result.warning.code, 'projection-open-failed');
})) passed += 1; else failed += 1;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
runTests();
+21 -6
View File
@@ -479,7 +479,7 @@ function runTests() {
);
})) passed++; else failed++;
if (test('plans antigravity remaps for workflows, skills, and flat rules', () => {
if (test('plans native Antigravity 2.0 rules, workflows, skills, and agents', () => {
const repoRoot = path.join(__dirname, '..', '..');
const projectRoot = '/workspace/app';
@@ -494,7 +494,11 @@ function runTests() {
},
{
id: 'agents-core',
paths: ['agents'],
paths: ['.agents', 'agents', 'AGENTS.md'],
},
{
id: 'workflow-quality',
paths: ['skills/tdd-workflow'],
},
{
id: 'rules-core',
@@ -506,24 +510,35 @@ function runTests() {
assert.ok(
plan.operations.some(operation => (
operation.sourceRelativePath === 'commands'
&& operation.destinationPath === path.join(projectRoot, '.agent', 'workflows')
&& operation.destinationPath === path.join(projectRoot, '.agents', 'workflows')
)),
'Should remap commands into workflows'
);
assert.ok(
plan.operations.some(operation => (
operation.sourceRelativePath === 'agents'
&& operation.destinationPath === path.join(projectRoot, '.agent', 'skills')
&& operation.destinationPath === path.join(projectRoot, '.agents', 'agents')
)),
'Should remap agents into skills'
'Should remap agents into native agents'
);
assert.ok(
plan.operations.some(operation => (
operation.sourceRelativePath === 'skills/tdd-workflow'
&& operation.destinationPath === path.join(projectRoot, '.agents', 'skills', 'tdd-workflow')
)),
'Should remap canonical skills into native skills'
);
assert.ok(
plan.operations.some(operation => (
normalizedRelativePath(operation.sourceRelativePath) === 'rules/common/coding-style.md'
&& operation.destinationPath === path.join(projectRoot, '.agent', 'rules', 'common-coding-style.md')
&& operation.destinationPath === path.join(projectRoot, '.agents', 'rules', 'common-coding-style.md')
)),
'Should flatten common rules for antigravity'
);
assert.ok(
plan.operations.every(operation => !['.agents', 'AGENTS.md'].includes(operation.sourceRelativePath)),
'Should exclude Codex-only .agents metadata and root AGENTS.md'
);
})) passed++; else failed++;
if (test('exposes validate and planOperations on adapters', () => {
+93
View File
@@ -359,6 +359,68 @@ async function runTests() {
}
})) passed += 1; else failed += 1;
if (await test('creates private state-store directories and atomically persists a private database file', async () => {
const testDir = createTempDir('ecc-state-private-');
const privateParent = path.join(testDir, 'new-parent', 'ecc');
const dbPath = path.join(privateParent, 'state.db');
try {
const store = await createStateStore({ dbPath });
store.close();
if (process.platform !== 'win32') {
assert.strictEqual(fs.statSync(path.join(testDir, 'new-parent')).mode & 0o777, 0o700);
assert.strictEqual(fs.statSync(privateParent).mode & 0o777, 0o700);
assert.strictEqual(fs.statSync(dbPath).mode & 0o777, 0o600);
}
assert.deepStrictEqual(
fs.readdirSync(privateParent).sort(),
['state.db']
);
} finally {
cleanupTempDir(testDir);
}
})) passed += 1; else failed += 1;
if (await test('refuses a final state database symlink without changing its target', async () => {
const testDir = createTempDir('ecc-state-final-link-');
const targetPath = path.join(testDir, 'outside.db');
const dbPath = path.join(testDir, 'state.db');
try {
fs.writeFileSync(targetPath, 'do not overwrite');
fs.symlinkSync(targetPath, dbPath);
await assert.rejects(
() => createStateStore({ dbPath }),
/symlink/i
);
assert.strictEqual(fs.readFileSync(targetPath, 'utf8'), 'do not overwrite');
} finally {
cleanupTempDir(testDir);
}
})) passed += 1; else failed += 1;
if (await test('refuses an intermediate state database symlink without writing outside the requested tree', async () => {
const testDir = createTempDir('ecc-state-parent-link-');
const outsideDir = path.join(testDir, 'outside');
const linkedParent = path.join(testDir, 'linked-parent');
const dbPath = path.join(linkedParent, 'ecc', 'state.db');
try {
fs.mkdirSync(outsideDir);
fs.symlinkSync(outsideDir, linkedParent, process.platform === 'win32' ? 'junction' : 'dir');
await assert.rejects(
() => createStateStore({ dbPath }),
/symlink/i
);
assert.strictEqual(fs.existsSync(path.join(outsideDir, 'ecc', 'state.db')), false);
} finally {
cleanupTempDir(testDir);
}
})) passed += 1; else failed += 1;
if (await test('stores sessions and returns detailed session views with workers, skill runs, and decisions', async () => {
const testDir = createTempDir('ecc-state-db-');
const dbPath = path.join(testDir, 'state.db');
@@ -658,6 +720,37 @@ async function runTests() {
}
})) passed += 1; else failed += 1;
if (await test('deletes install projections by exact target id and root', async () => {
const store = await createStateStore({ dbPath: ':memory:' });
try {
store.upsertInstallState({
targetId: 'claude-home',
targetRoot: '/tmp/one/.claude',
sourceVersion: '2.2.0',
});
store.upsertInstallState({
targetId: 'claude-home',
targetRoot: '/tmp/two/.claude',
sourceVersion: '2.2.0',
});
assert.strictEqual(store.deleteInstallState({
targetId: 'claude-home',
targetRoot: '/tmp/one/.claude',
}), true);
assert.strictEqual(store.deleteInstallState({
targetId: 'claude-home',
targetRoot: '/tmp/missing/.claude',
}), false);
assert.deepStrictEqual(
store.getStatus().installHealth.installations.map(row => row.targetRoot),
['/tmp/two/.claude']
);
} finally {
store.close();
}
})) passed += 1; else failed += 1;
if (await test('rejects invalid limits and unserializable JSON payloads', async () => {
const testDir = createTempDir('ecc-state-errors-');
const dbPath = path.join(testDir, 'state.db');
+114
View File
@@ -388,6 +388,120 @@ function runTests() {
}
})) passed += 1; else failed += 1;
if (test('runAutoUpdate excludes residual legacy Antigravity records', () => {
const homeDir = createTempDir('auto-update-home-');
const projectRoot = createTempDir('auto-update-project-');
const repoRoot = createTempDir('auto-update-repo-');
try {
ensureFakeRepo(repoRoot);
const canonical = makeRecord({
repoRoot,
homeDir,
projectRoot,
adapter: { id: 'antigravity-project', target: 'antigravity', kind: 'project' },
request: {
profile: null,
modules: [],
includeComponents: [],
excludeComponents: [],
legacyLanguages: ['typescript'],
legacyMode: true,
},
resolution: { selectedModules: ['legacy-antigravity-install'], skippedModules: [] },
operations: [],
});
const legacy = {
...canonical,
installStatePath: path.join(projectRoot, '.agent', 'ecc-install-state.json'),
legacy: true,
};
const commands = [];
const result = runAutoUpdate(
{
homeDir,
projectRoot,
repoRoot,
dryRun: true,
},
{
discoverInstalledStates: () => [canonical, legacy],
runExternalCommand(command, args) {
commands.push({ command, args });
return {
stdout: JSON.stringify({ dryRun: true, plan: {} }),
stderr: '',
};
},
}
);
assert.strictEqual(result.summary.checkedCount, 1);
assert.strictEqual(result.summary.updatedCount, 1);
assert.strictEqual(commands.length, 1);
assert.strictEqual(commands[0].command, process.execPath);
} finally {
cleanup(homeDir);
cleanup(projectRoot);
cleanup(repoRoot);
}
})) passed += 1; else failed += 1;
if (test('runAutoUpdate explains a legacy-only Antigravity install', () => {
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: 'antigravity-project', target: 'antigravity', kind: 'project' },
request: {
profile: null,
modules: [],
includeComponents: [],
excludeComponents: [],
legacyLanguages: ['typescript'],
legacyMode: true,
},
resolution: { selectedModules: ['legacy-antigravity-install'], skippedModules: [] },
operations: [],
}),
installStatePath: path.join(projectRoot, '.agent', 'ecc-install-state.json'),
legacy: true,
};
const commands = [];
const result = runAutoUpdate(
{ homeDir, projectRoot, repoRoot, dryRun: true },
{
discoverInstalledStates: () => [legacy],
runExternalCommand(command, args) {
commands.push({ command, args });
},
}
);
assert.deepStrictEqual(result.results, []);
assert.strictEqual(result.summary.checkedCount, 0);
assert.strictEqual(result.summary.updatedCount, 0);
assert.strictEqual(result.summary.errorCount, 0);
assert.strictEqual(commands.length, 0);
assert.ok(result.warnings.some(warning => warning.includes(
'Run the Antigravity installer once to migrate it to .agents'
)));
} 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);
}
+81 -15
View File
@@ -7,6 +7,7 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync, spawnSync } = require('child_process');
const yaml = require('js-yaml');
const { applyInstallPlan } = require('../../scripts/lib/install/apply');
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'install-apply.js');
@@ -24,6 +25,13 @@ function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function readMarkdownFrontmatter(filePath) {
const source = fs.readFileSync(filePath, 'utf8');
const match = source.match(/^---\n([\s\S]*?)\n---\n/);
assert.ok(match, `Expected YAML frontmatter in ${filePath}`);
return yaml.load(match[1]);
}
function run(args = [], options = {}) {
const homeDir = options.homeDir || process.env.HOME;
const env = {
@@ -279,20 +287,48 @@ function runTests() {
const result = run(['--target', 'antigravity', 'typescript'], { cwd: projectDir, homeDir });
assert.strictEqual(result.code, 0, result.stderr);
assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'rules', 'common-coding-style.md')));
assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'rules', 'typescript-testing.md')));
assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'workflows', 'plan.md')));
assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'skills', 'architect.md')));
assert.ok(fs.existsSync(path.join(projectDir, '.agents', 'rules', 'common-coding-style.md')));
assert.ok(fs.existsSync(path.join(projectDir, '.agents', 'rules', 'typescript-testing.md')));
assert.ok(!fs.existsSync(path.join(projectDir, '.agents', 'rules', 'python-testing.md')));
assert.ok(fs.existsSync(path.join(projectDir, '.agents', 'workflows', 'plan.md')));
assert.ok(fs.existsSync(path.join(projectDir, '.agents', 'skills', 'tdd-workflow', 'SKILL.md')));
assert.ok(fs.existsSync(path.join(projectDir, '.agents', 'agents', 'architect.md')));
const tddGuide = readMarkdownFrontmatter(
path.join(projectDir, '.agents', 'agents', 'tdd-guide.md')
);
assert.deepStrictEqual(
tddGuide.tools,
['view_file', 'write_to_file', 'replace_file_content', 'run_command', 'grep_search']
);
assert.strictEqual(tddGuide.model, 'pro');
const docsLookup = readMarkdownFrontmatter(
path.join(projectDir, '.agents', 'agents', 'docs-lookup.md')
);
assert.deepStrictEqual(docsLookup.tools, ['view_file', 'grep_search']);
const harnessOptimizer = readMarkdownFrontmatter(
path.join(projectDir, '.agents', 'agents', 'harness-optimizer.md')
);
assert.ok(!Object.hasOwn(harnessOptimizer, 'color'), 'Should omit Claude-only color metadata');
const statePath = path.join(projectDir, '.agent', 'ecc-install-state.json');
const statePath = path.join(projectDir, '.agents', 'ecc-install-state.json');
const state = readJson(statePath);
assert.strictEqual(state.target.id, 'antigravity-project');
assert.deepStrictEqual(state.request.legacyLanguages, ['typescript']);
assert.strictEqual(state.request.legacyMode, true);
assert.deepStrictEqual(state.resolution.selectedModules, ['rules-core', 'agents-core', 'commands-core']);
assert.deepStrictEqual(
state.resolution.selectedModules,
[
'rules-core',
'agents-core',
'commands-core',
'platform-configs',
'skill-unified-memory',
'workflow-quality',
]
);
assert.ok(
state.operations.some(operation => (
operation.destinationPath.endsWith(path.join('.agent', 'workflows', 'plan.md'))
operation.destinationPath.endsWith(path.join('.agents', 'workflows', 'plan.md'))
)),
'Should record manifest command file copy operation'
);
@@ -302,6 +338,35 @@ function runTests() {
}
})) passed++; else failed++;
if (test('maps legacy language aliases to Antigravity rule namespaces', () => {
const homeDir = createTempDir('install-apply-home-');
const projectDir = createTempDir('install-apply-project-');
try {
const result = run(
['--target', 'antigravity', 'c', 'go', 'kotlin', 'javascript', 'rails', 'harmonyos'],
{ cwd: projectDir, homeDir }
);
assert.strictEqual(result.code, 0, result.stderr);
const rulesDir = path.join(projectDir, '.agents', 'rules');
for (const fileName of [
'golang-testing.md',
'kotlin-testing.md',
'typescript-testing.md',
'ruby-testing.md',
'arkts-testing.md',
'cpp-testing.md',
]) {
assert.ok(fs.existsSync(path.join(rulesDir, fileName)), `Expected ${fileName}`);
}
assert.ok(!fs.existsSync(path.join(rulesDir, 'python-testing.md')));
} finally {
cleanup(homeDir);
cleanup(projectDir);
}
})) passed++; else failed++;
if (test('installs JoyCode profile through managed install-state', () => {
const homeDir = createTempDir('install-apply-home-');
const projectDir = createTempDir('install-apply-project-');
@@ -613,15 +678,16 @@ function runTests() {
const result = run(['--target', 'antigravity', '--profile', 'core'], { cwd: projectDir, homeDir });
assert.strictEqual(result.code, 0, result.stderr);
assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'rules', 'common-coding-style.md')));
assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'skills', 'architect.md')));
assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'workflows', 'plan.md')));
// .agent/skills is where antigravity keeps its agents, and ECC agents are
// already mapped there. Installing ECC skills into the same directory made
// the two collide, so skills are no longer an antigravity source path.
assert.ok(!fs.existsSync(path.join(projectDir, '.agent', 'skills', 'tdd-workflow', 'SKILL.md')));
assert.ok(fs.existsSync(path.join(projectDir, '.agents', 'rules', 'common-coding-style.md')));
assert.ok(
fs.existsSync(path.join(projectDir, '.agents', 'rules', 'python-testing.md')),
'Manifest profiles should retain broad rule coverage'
);
assert.ok(fs.existsSync(path.join(projectDir, '.agents', 'agents', 'architect.md')));
assert.ok(fs.existsSync(path.join(projectDir, '.agents', 'workflows', 'plan.md')));
assert.ok(fs.existsSync(path.join(projectDir, '.agents', 'skills', 'tdd-workflow', 'SKILL.md')));
const state = readJson(path.join(projectDir, '.agent', 'ecc-install-state.json'));
const state = readJson(path.join(projectDir, '.agents', 'ecc-install-state.json'));
assert.strictEqual(state.request.profile, 'core');
assert.strictEqual(state.request.legacyMode, false);
assert.deepStrictEqual(
@@ -78,6 +78,10 @@ function buildExpectedPublishPaths(repoRoot) {
"scripts/welcome.js",
"scripts/gemini-adapt-agents.js",
"scripts/sync-ecc-to-codex.sh",
"scripts/codex/legacy-sync-state.js",
"scripts/codex/install-global-git-hooks.sh",
"scripts/codex/check-codex-global-state.sh",
"scripts/codex-git-hooks",
"scripts/codex/check-plugin-cache.js",
"scripts/codex/merge-codex-config.js",
"scripts/codex/merge-mcp-config.js",
@@ -166,6 +170,11 @@ function main() {
"scripts/work-items.js",
"scripts/platform-audit.js",
"scripts/sync-ecc-to-codex.sh",
"scripts/codex/legacy-sync-state.js",
"scripts/codex/install-global-git-hooks.sh",
"scripts/codex/check-codex-global-state.sh",
"scripts/codex-git-hooks/pre-commit",
"scripts/codex-git-hooks/pre-push",
"scripts/setup.js",
"scripts/codex/check-plugin-cache.js",
".gemini/GEMINI.md",
+2 -1
View File
@@ -54,7 +54,8 @@ for (const workflow of [
});
test(`${workflow} publishes new tag versions to npm`, () => {
assert.match(content, /npm publish "\$\{\{ needs\.verify\.outputs\.package_file \}\}" --access public --provenance/);
assert.match(content, /ECC_RELEASE_PACKAGE:\s*\$\{\{ needs\.verify\.outputs\.package_file \}\}/);
assert.match(content, /npm publish "\.\/\$\{ECC_RELEASE_PACKAGE\}" --access public --provenance/);
assert.match(content, /NODE_AUTH_TOKEN:\s*\$\{\{\s*secrets\.NPM_TOKEN\s*\}\}/);
});
+2 -2
View File
@@ -161,11 +161,11 @@ function runTests() {
if (test('reusable release checks out the requested tag before validating and publishing', () => {
const checkoutIndex = reusableReleaseWorkflowSource.indexOf('uses: actions/checkout@');
const refIndex = reusableReleaseWorkflowSource.indexOf('ref: ${{ inputs.tag }}');
const refIndex = reusableReleaseWorkflowSource.indexOf('ref: refs/tags/${{ inputs.tag }}');
const validateIndex = reusableReleaseWorkflowSource.indexOf('name: Validate version tag');
assert.ok(checkoutIndex >= 0, 'reusable-release.yml should check out repository content');
assert.ok(refIndex >= 0, 'reusable-release.yml checkout should use inputs.tag as ref');
assert.ok(refIndex >= 0, 'reusable-release.yml checkout should require inputs.tag to resolve as a tag');
assert.ok(validateIndex >= 0, 'reusable-release.yml should validate requested tag');
assert.ok(
checkoutIndex < refIndex && refIndex < validateIndex,
+17
View File
@@ -104,6 +104,23 @@ function runTests() {
assert.ok(source.includes('node - "$file"'), 'extract_context7_key should use Node-based parsing');
})) passed++; else failed++;
if (test('sync records a versioned ownership manifest before mutating Codex state', () => {
const beginIndex = source.indexOf('"$LEGACY_STATE_HELPER" begin');
const configMergeIndex = source.indexOf('node "$BASELINE_MERGE_SCRIPT" "$CONFIG_FILE"');
const finalizeIndex = source.indexOf('"$LEGACY_STATE_HELPER" finalize');
assert.ok(beginIndex > -1, 'legacy manifest begin is missing');
assert.ok(configMergeIndex > beginIndex, 'manifest must begin before config mutation');
assert.ok(finalizeIndex > configMergeIndex, 'manifest must finalize after managed writes');
assert.ok(source.includes('record_managed_path "$out"'), 'generated prompts must be recorded');
assert.ok(source.includes('record_managed_path "${ECC_GLOBAL_HOOKS_DIR:-$CODEX_HOME/git-hooks}/pre-commit"'));
})) passed++; else failed++;
if (test('sync inherits its ERR trap so helper failures trigger rollback', () => {
assert.match(source, /^set -Eeuo pipefail$/m);
assert.ok(source.includes("trap 'rollback_legacy_sync $?' ERR"));
assert.ok(source.includes('node "$LEGACY_STATE_HELPER" rollback --state "$LEGACY_STATE_PATH"'));
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
+71
View File
@@ -282,6 +282,77 @@ function runTests() {
}
})) passed++; else failed++;
if (test('reports preserved legacy Antigravity files as an incomplete uninstall', () => {
const homeDir = createTempDir('uninstall-home-');
const projectRoot = createTempDir('uninstall-project-');
try {
const targetRoot = path.join(projectRoot, '.agent');
fs.mkdirSync(path.join(targetRoot, 'rules'), { recursive: true });
const normalizedTargetRoot = fs.realpathSync(targetRoot);
const statePath = path.join(normalizedTargetRoot, 'ecc-install-state.json');
const editedPath = path.join(normalizedTargetRoot, 'rules', 'common-coding-style.md');
fs.writeFileSync(editedPath, 'customer edit\n');
writeState(statePath, {
adapter: { id: 'antigravity-project', target: 'antigravity', kind: 'project' },
targetRoot: normalizedTargetRoot,
installStatePath: statePath,
request: {
profile: null,
modules: [],
includeComponents: [],
excludeComponents: [],
legacyLanguages: ['typescript'],
legacyMode: true,
},
resolution: {
selectedModules: ['legacy-antigravity-install'],
skippedModules: [],
},
operations: [{
kind: 'copy-file',
moduleId: 'rules-core',
sourceRelativePath: 'rules/common/coding-style.md',
destinationPath: editedPath,
strategy: 'flatten-copy',
ownership: 'managed',
scaffoldOnly: false,
}],
source: {
repoVersion: CURRENT_PACKAGE_VERSION,
repoCommit: 'abc123',
manifestVersion: CURRENT_MANIFEST_VERSION,
},
});
const dryRun = run(['--target', 'antigravity', '--dry-run', '--json'], {
cwd: projectRoot,
homeDir,
});
assert.strictEqual(dryRun.code, 1);
const parsed = JSON.parse(dryRun.stdout);
assert.strictEqual(parsed.results[0].status, 'partial');
assert.deepStrictEqual(parsed.results[0].plannedRemovals, []);
assert.deepStrictEqual(parsed.results[0].retainedPaths, [editedPath]);
assert.strictEqual(parsed.summary.partialCount, 1);
const applied = run(['--target', 'antigravity'], {
cwd: projectRoot,
homeDir,
});
assert.strictEqual(applied.code, 1);
assert.ok(applied.stdout.includes('Status: PARTIAL'));
assert.ok(applied.stdout.includes('Legacy Antigravity files were preserved'));
assert.ok(applied.stdout.includes(editedPath));
assert.ok(fs.existsSync(editedPath));
assert.ok(fs.existsSync(statePath));
} finally {
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
+353
View File
@@ -0,0 +1,353 @@
/**
* Regression tests for #2774: repo-scan installation must be reproducible.
*/
'use strict';
const assert = require('assert');
const { spawnSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..', '..');
const skillFiles = [
{
relativePath: path.join('skills', 'repo-scan', 'SKILL.md'),
heading: '## Installation',
descriptionTerms: ['bootstrap', 'external', 'install'],
reinvocationText: 'Reload your agent harness, then invoke `repo-scan` again',
},
{
relativePath: path.join('docs', 'zh-CN', 'skills', 'repo-scan', 'SKILL.md'),
heading: '## 安装',
descriptionTerms: ['引导', '外部', '安装'],
reinvocationText: '重新加载智能体运行环境,然后再次调用 `repo-scan`',
},
{
relativePath: path.join('docs', 'ja-JP', 'skills', 'repo-scan', 'SKILL.md'),
heading: '## インストール',
descriptionTerms: ['ブートストラップ', '外部', 'インストール'],
reinvocationText: 'エージェントハーネスを再読み込みしてから、`repo-scan` を再度呼び出してください',
}
];
const pinnedCommit = '2742664ebcad1450c208eda0ae45d3c17fad5dd8';
const bashBinary = process.env.ECC_TEST_BASH || (process.platform === 'win32' ? null : 'bash');
function run(command, args, options = {}) {
return spawnSync(command, args, {
encoding: 'utf8',
...options,
env: { ...process.env, ...(options.env || {}) },
});
}
function toShellPath(filePath) {
const normalized = filePath.replace(/\\/g, '/');
return normalized.replace(/^([A-Za-z]):\//, (_, drive) => `/${drive.toLowerCase()}/`);
}
function writeExecutable(filePath, content) {
fs.writeFileSync(filePath, content, { encoding: 'utf8', mode: 0o755 });
fs.chmodSync(filePath, 0o755);
}
function requireShellCommand(command) {
const result = run(bashBinary, ['-lc', `command -v ${command}`]);
assert.strictEqual(result.status, 0, result.stderr);
return result.stdout.trim();
}
function createLocalSource(root) {
const sourceRepo = path.join(root, 'source-repo');
fs.mkdirSync(sourceRepo, { recursive: true });
assert.strictEqual(run('git', ['init', '--quiet'], { cwd: sourceRepo }).status, 0);
fs.writeFileSync(path.join(sourceRepo, 'SKILL.md'), 'pinned fixture\n');
fs.mkdirSync(path.join(sourceRepo, 'scripts'));
fs.writeFileSync(path.join(sourceRepo, 'scripts', 'scan.sh'), '#!/bin/sh\n');
assert.strictEqual(run('git', ['add', '.'], { cwd: sourceRepo }).status, 0);
const commit = run('git', ['commit', '--quiet', '-m', 'fixture'], {
cwd: sourceRepo,
env: {
GIT_AUTHOR_NAME: 'Test',
GIT_AUTHOR_EMAIL: 'test@example.com',
GIT_COMMITTER_NAME: 'Test',
GIT_COMMITTER_EMAIL: 'test@example.com',
},
});
assert.strictEqual(commit.status, 0, commit.stderr);
return sourceRepo;
}
function createCommandShims(root) {
const binDir = path.join(root, 'bin');
fs.mkdirSync(binDir);
writeExecutable(path.join(binDir, 'git'), `#!/usr/bin/env bash
set -euo pipefail
if [ "\${1:-}" = clone ]; then
target="\${!#}"
exec "$REAL_GIT" clone --quiet "$LOCAL_REPO" "$target"
fi
if [ "\${1:-}" = -C ] && [ "\${3:-}" = checkout ]; then
exec "$REAL_GIT" -C "$2" checkout --quiet --detach HEAD
fi
if [ "\${1:-}" = -C ] && [ "\${3:-}" = archive ]; then
exec "$REAL_GIT" -C "$2" archive HEAD
fi
exec "$REAL_GIT" "$@"
`);
writeExecutable(path.join(binDir, 'mv'), `#!/usr/bin/env bash
set -euo pipefail
original_args=("$@")
no_target=0
positional=()
for arg in "$@"; do
case "$arg" in
-T) no_target=1 ;;
--) ;;
*) positional+=("$arg") ;;
esac
done
source_path="\${positional[0]:-}"
destination="\${positional[1]:-}"
case "$source_path" in
*/mv-probe-source)
case "\${REPO_SCAN_TEST_MV_FAILURE:-}" in
*-portable) if [ "$no_target" -eq 1 ]; then exit 64; fi ;;
esac
exec "$REAL_MV" "\${original_args[@]}"
;;
esac
case "$source_path" in
*/stage-*)
case "\${REPO_SCAN_TEST_MV_FAILURE:-}" in
replace|rollback) exit 73 ;;
rollback-target-conflict|rollback-target-conflict-portable) exit 73 ;;
target-conflict|target-conflict-portable)
if [ ! -e "$SHIM_DIR/conflict-created" ]; then
mkdir -p -- "$destination"
printf 'concurrent installation\n' > "$destination/concurrent-marker.txt"
: > "$SHIM_DIR/conflict-created"
fi
;;
esac
;;
*/backup-*)
case "\${REPO_SCAN_TEST_MV_FAILURE:-}" in
rollback) exit 74 ;;
rollback-target-conflict|rollback-target-conflict-portable)
if [ ! -e "$SHIM_DIR/conflict-created" ]; then
mkdir -p -- "$destination"
printf 'concurrent installation\n' > "$destination/concurrent-marker.txt"
: > "$SHIM_DIR/conflict-created"
fi
;;
esac
;;
esac
exec "$REAL_MV" "\${original_args[@]}"
`);
return binDir;
}
function transactionDirs(installParent) {
if (!fs.existsSync(installParent)) return [];
return fs.readdirSync(installParent).filter(
name => name.startsWith('.repo-scan-install.') && name !== '.repo-scan-install.lock'
);
}
function prepareInstallScenario(installParent, installDir, scenario) {
if (scenario === 'fresh') return;
fs.mkdirSync(installDir, { recursive: true });
fs.writeFileSync(path.join(installDir, 'old-marker.txt'), 'previous installation\n');
if (scenario === 'lock-held') {
fs.mkdirSync(path.join(installParent, '.repo-scan-install.lock'));
}
}
function failureMode(scenario) {
if (scenario === 'replacement-failure') return 'replace';
if (scenario === 'rollback-failure') return 'rollback';
if (scenario.includes('target-conflict')) return scenario;
return '';
}
function assertPreservedBackup(result, installParent) {
const workspaces = transactionDirs(installParent);
assert.strictEqual(workspaces.length, 1, result.stderr);
const workspace = path.join(installParent, workspaces[0]);
const backupName = fs.readdirSync(workspace).find(name => name.startsWith('backup-'));
assert.ok(backupName, result.stderr);
const preservedBackup = path.join(workspace, backupName);
assert.strictEqual(
fs.readFileSync(path.join(preservedBackup, 'old-marker.txt'), 'utf8'),
'previous installation\n'
);
}
function assertInstallationResult({ result, scenario, installDir, installParent }) {
const lockDir = path.join(installParent, '.repo-scan-install.lock');
if (scenario === 'fresh' || scenario === 'existing') {
assert.strictEqual(result.status, 0, result.stderr);
assert.strictEqual(fs.readFileSync(path.join(installDir, 'SKILL.md'), 'utf8').trim(), 'pinned fixture');
assert.ok(!fs.existsSync(path.join(installDir, '.git')));
assert.ok(!fs.existsSync(path.join(installDir, 'old-marker.txt')));
assert.deepStrictEqual(transactionDirs(installParent), []);
assert.ok(!fs.existsSync(lockDir));
return;
}
assert.notStrictEqual(result.status, 0, 'forced installation failure must propagate');
if (scenario === 'replacement-failure' || scenario === 'lock-held') {
assert.strictEqual(
fs.readFileSync(path.join(installDir, 'old-marker.txt'), 'utf8'),
'previous installation\n'
);
assert.deepStrictEqual(transactionDirs(installParent), []);
assert.strictEqual(fs.existsSync(lockDir), scenario === 'lock-held');
if (scenario === 'lock-held') assert.match(result.stderr, /holds the lock/);
return;
}
assertPreservedBackup(result, installParent);
assert.ok(!fs.existsSync(lockDir));
if (scenario.includes('target-conflict')) {
assert.ok(fs.existsSync(path.join(installDir, 'concurrent-marker.txt')));
assert.ok(
!fs.readdirSync(installDir).some(name => /^(stage|backup)-/.test(name)),
'native mv must not leave staged or backup directories nested in the target'
);
assert.match(result.stderr, /target was recreated|rollback failed/);
} else {
assert.match(result.stderr, /previous installation preserved at/);
}
}
function executeInstallation(block, scenario) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-repo-scan-install-'));
try {
const sourceRepo = createLocalSource(root);
const binDir = createCommandShims(root);
const configDir = path.join(root, 'config');
const installParent = path.join(configDir, 'skills');
const installDir = path.join(installParent, 'repo-scan');
prepareInstallScenario(installParent, installDir, scenario);
const result = run(bashBinary, ['-c', `export PATH="$SHIM_DIR:$PATH"\n${block}`], {
input: 'install\n',
cwd: repoRoot,
env: {
CLAUDE_CONFIG_DIR: toShellPath(configDir),
LOCAL_REPO: toShellPath(sourceRepo),
REAL_GIT: requireShellCommand('git'),
REAL_MV: requireShellCommand('mv'),
REPO_SCAN_TEST_MV_FAILURE: failureMode(scenario),
SHIM_DIR: toShellPath(binDir),
},
timeout: 30000,
});
assertInstallationResult({ result, scenario, installDir, installParent });
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
}
function installationBlock({ relativePath, heading }) {
const source = fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
const headingStart = source.indexOf(`${heading}\n`);
assert.notStrictEqual(headingStart, -1, `${relativePath} must contain ${heading}`);
const afterHeading = source.slice(headingStart + heading.length + 1);
const nextHeading = afterHeading.search(/^## /m);
const installationSection = nextHeading === -1 ? afterHeading : afterHeading.slice(0, nextHeading);
const match = installationSection.match(/```bash\n([\s\S]*?)```/);
assert.ok(match, `${relativePath} must contain a bash installation block`);
return match[1];
}
function assertPointerContract({ relativePath, descriptionTerms, reinvocationText }) {
const source = fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
const frontmatter = source.match(/^---\n([\s\S]*?)\n---/);
assert.ok(frontmatter, `${relativePath} must contain YAML frontmatter`);
const description = frontmatter[1].match(/^description:\s*(.+)$/m);
assert.ok(description, `${relativePath} must contain a frontmatter description`);
for (const term of descriptionTerms) {
assert.ok(
description[1].toLocaleLowerCase().includes(term.toLocaleLowerCase()),
`${relativePath} description must identify this as an external installer pointer (${term})`
);
}
assert.ok(
source.includes(reinvocationText),
`${relativePath} must tell users to reload and invoke repo-scan again after installation`
);
}
console.log('\nrepo-scan installation docs (#2774):');
const blocks = skillFiles.map(installationBlock);
let passed = 0;
for (const skillFile of skillFiles) {
assertPointerContract(skillFile);
passed++;
}
for (const [index, block] of blocks.entries()) {
const { relativePath } = skillFiles[index];
assert.ok(block.includes(`REPO_SCAN_COMMIT=${pinnedCommit}`), `${relativePath} must pin the full commit SHA`);
assert.ok(block.includes('set -euo pipefail'), `${relativePath} must fail closed`);
assert.ok(block.includes('mktemp -d "$REPO_SCAN_INSTALL_PARENT/'), `${relativePath} must stage on the target filesystem`);
assert.ok(block.includes('REPO_SCAN_KEEP_TMP=0'), `${relativePath} must track cleanup safety`);
assert.ok(block.includes('REPO_SCAN_LOCK_HELD=0'), `${relativePath} must track lock ownership`);
assert.ok(block.includes('REPO_SCAN_MV_HAS_NO_TARGET=0'), `${relativePath} must probe no-target moves`);
assert.ok(block.includes('trap cleanup_repo_scan_install EXIT'), `${relativePath} must use conditional cleanup`);
assert.ok(block.includes('mv -T -- "$REPO_SCAN_MOVE_SOURCE"'), `${relativePath} must reject an existing GNU mv destination`);
assert.ok(block.includes('move_repo_scan_dir()'), `${relativePath} must guard portable directory moves`);
assert.ok(block.includes('git clone --filter=blob:none --no-checkout'), `${relativePath} must clone before checkout`);
assert.ok(block.includes('checkout --detach "$REPO_SCAN_COMMIT"'), `${relativePath} must detach at the pin`);
assert.ok(block.includes('archive "$REPO_SCAN_COMMIT"'), `${relativePath} must archive the exact pinned commit`);
assert.ok(block.includes('tar -xf - -C "$REPO_SCAN_STAGE"'), `${relativePath} must extract into a fresh staging directory`);
assert.ok(block.includes('# Review "$REPO_SCAN_TMP/source"'), `${relativePath} must instruct source review`);
assert.ok(block.includes('read -r REPO_SCAN_CONFIRM'), `${relativePath} must require explicit confirmation`);
assert.ok(block.includes('mkdir -- "$REPO_SCAN_LOCK"'), `${relativePath} must serialize replacement`);
assert.ok(block.includes('[ "$REPO_SCAN_CONFIRM" != install ]'), `${relativePath} must default-deny installation`);
assert.ok(block.includes('move_repo_scan_dir "$REPO_SCAN_STAGE" "$REPO_SCAN_INSTALL_DIR"'), `${relativePath} must use guarded replacement`);
assert.ok(block.indexOf('read -r REPO_SCAN_CONFIRM') < block.indexOf('move_repo_scan_dir "$REPO_SCAN_STAGE"'), `${relativePath} must confirm before replacing the target`);
assert.ok(block.indexOf('mkdir -- "$REPO_SCAN_LOCK"') < block.indexOf('move_repo_scan_dir "$REPO_SCAN_STAGE"'), `${relativePath} must lock before replacing the target`);
assert.ok(!block.includes('rm -rf "$REPO_SCAN_INSTALL_DIR"'), `${relativePath} must preserve the old target until replacement succeeds`);
assert.ok(block.includes('${CLAUDE_CONFIG_DIR:-$HOME/.claude}'), `${relativePath} must honor CLAUDE_CONFIG_DIR`);
assert.ok(!block.includes('cp -r .'), `${relativePath} must not copy .git metadata`);
assert.ok(!block.includes('git fetch --depth 1 origin 2742664\n'), `${relativePath} must not fetch the short SHA`);
assert.ok(block.includes('REPO_SCAN_KEEP_TMP=1'), `${relativePath} must preserve a failed rollback backup`);
passed++;
}
for (const block of blocks.slice(1)) {
assert.strictEqual(block, blocks[0], 'translated installation commands must stay synchronized');
passed++;
}
if (bashBinary) {
for (const block of blocks) {
const syntax = run(bashBinary, ['-n'], { input: block });
assert.strictEqual(syntax.status, 0, syntax.stderr);
passed++;
for (const scenario of [
'fresh',
'existing',
'replacement-failure',
'rollback-failure',
'target-conflict',
'target-conflict-portable',
'rollback-target-conflict',
'rollback-target-conflict-portable',
'lock-held',
]) {
executeInstallation(block, scenario);
passed++;
}
}
} else {
console.log(' Integration coverage skipped on Windows without ECC_TEST_BASH');
}
console.log(` Passed: ${passed}`);
console.log(' Failed: 0');
+5 -4
View File
@@ -586,6 +586,7 @@ __metadata:
c8: "npm:11.0.0"
eslint: "npm:10.6.0"
globals: "npm:17.4.0"
js-yaml: "npm:4.3.1"
markdownlint-cli: "npm:0.48.0"
sql.js: "npm:1.14.1"
typescript: "npm:6.0.3"
@@ -1068,14 +1069,14 @@ __metadata:
languageName: node
linkType: hard
"js-yaml@npm:4.3.0":
version: 4.3.0
resolution: "js-yaml@npm:4.3.0"
"js-yaml@npm:4.3.1":
version: 4.3.1
resolution: "js-yaml@npm:4.3.1"
dependencies:
argparse: "npm:^2.0.1"
bin:
js-yaml: bin/js-yaml.js
checksum: 10c0/058b30473d6915ca5b4feb11e2f7d4d97242f98d00a798ed48dd90b46b7c640398afe9128c5db22c5300f8c6528fe2a174b9a93f351a70ebc28c6203938d8bff
checksum: 10c0/13c500ca322e0c3f8c81686e6ecda96d2ea37b45247a420c17c7db36932d6965cc27391abc2d1a104501600e7f0d947a5f8b7be6db619c4fefa87901b3512807
languageName: node
linkType: hard