mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-17 21:15:40 +02:00
Merge pull request #2784 from affaan-m/fix/installer-hotfix-2.2
fix(install): harden ECC installer lifecycle
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,6 +15,7 @@ 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 }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -104,8 +105,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
|
||||
@@ -114,12 +114,52 @@ jobs:
|
||||
path: |
|
||||
release_body.md
|
||||
${{ steps.pack.outputs.package_file }}
|
||||
tests/ci/packed-artifact-lifecycle.js
|
||||
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: 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 release-artifacts/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 +176,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 +194,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}"
|
||||
|
||||
@@ -38,13 +38,14 @@ 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 }}
|
||||
|
||||
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: Setup Node.js
|
||||
@@ -62,9 +63,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 +122,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
|
||||
@@ -134,12 +131,52 @@ jobs:
|
||||
path: |
|
||||
release_body.md
|
||||
${{ steps.pack.outputs.package_file }}
|
||||
tests/ci/packed-artifact-lifecycle.js
|
||||
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: 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 release-artifacts/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 +193,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 +212,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}"
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 ポインターは外部スキルをインストールするだけで、スキャン自体は実行しません。
|
||||
|
||||
## コア機能
|
||||
|
||||
| 機能 | 説明 |
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 指针仅安装外部技能,本身不会执行扫描。
|
||||
|
||||
## 核心能力
|
||||
|
||||
| 能力 | 描述 |
|
||||
|
||||
Generated
+4
-5
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/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);
|
||||
if (index === -1) return null;
|
||||
const value = args[index + 1];
|
||||
if (!value || value.startsWith('--')) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
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]');
|
||||
}
|
||||
|
||||
module.exports = { main, readFlag };
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
process.stderr.write(`[ecc-sync] ERROR: ${error.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,603 @@
|
||||
'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 openRegularFileNoFollow(filePath, writable = false) {
|
||||
const noFollow = fs.constants.O_NOFOLLOW || 0;
|
||||
const flags = (writable ? fs.constants.O_RDWR : fs.constants.O_RDONLY) | noFollow;
|
||||
let descriptor;
|
||||
try {
|
||||
descriptor = fs.openSync(filePath, flags);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
try {
|
||||
const unresolved = fs.lstatSync(filePath);
|
||||
if (unresolved.isSymbolicLink() || !unresolved.isFile()) {
|
||||
throw new Error(`Refusing to manage non-regular legacy sync path: ${filePath}`);
|
||||
}
|
||||
} catch (lstatError) {
|
||||
if (lstatError.code === 'ENOENT') return null;
|
||||
throw lstatError;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (error.code === 'ELOOP') {
|
||||
throw new Error(`Refusing to manage non-regular legacy sync path: ${filePath}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const descriptorStat = fs.fstatSync(descriptor, { bigint: true });
|
||||
let finalPathStat;
|
||||
try {
|
||||
finalPathStat = fs.lstatSync(filePath, { bigint: true });
|
||||
} catch (error) {
|
||||
fs.closeSync(descriptor);
|
||||
if (error.code === 'ENOENT') {
|
||||
throw new Error(`Legacy sync path changed while opening: ${filePath}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
!descriptorStat.isFile()
|
||||
|| !finalPathStat.isFile()
|
||||
|| finalPathStat.isSymbolicLink()
|
||||
|| descriptorStat.dev !== finalPathStat.dev
|
||||
|| descriptorStat.ino !== finalPathStat.ino
|
||||
|| descriptorStat.nlink !== 1n
|
||||
|| finalPathStat.nlink !== 1n
|
||||
) {
|
||||
fs.closeSync(descriptor);
|
||||
throw new Error(`Refusing to manage non-regular legacy sync path: ${filePath}`);
|
||||
}
|
||||
return { descriptor, stat: fs.fstatSync(descriptor) };
|
||||
}
|
||||
|
||||
function readRegularFileNoFollow(filePath, encoding = null) {
|
||||
const opened = openRegularFileNoFollow(filePath);
|
||||
if (!opened) return null;
|
||||
try {
|
||||
return {
|
||||
content: fs.readFileSync(opened.descriptor, encoding || undefined),
|
||||
mode: opened.stat.mode & 0o777,
|
||||
};
|
||||
} finally {
|
||||
fs.closeSync(opened.descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function replaceOpenedRegularFile(opened, content, mode = null) {
|
||||
const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content);
|
||||
fs.ftruncateSync(opened.descriptor, 0);
|
||||
fs.writeSync(opened.descriptor, buffer, 0, buffer.length, 0);
|
||||
if (mode) fs.fchmodSync(opened.descriptor, mode);
|
||||
fs.fsyncSync(opened.descriptor);
|
||||
}
|
||||
|
||||
function createRegularFileNoFollow(filePath, content, mode = 0o600) {
|
||||
const noFollow = fs.constants.O_NOFOLLOW || 0;
|
||||
const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollow;
|
||||
const descriptor = fs.openSync(filePath, flags, mode);
|
||||
try {
|
||||
const stat = fs.fstatSync(descriptor);
|
||||
if (!stat.isFile()) {
|
||||
throw new Error(`Refusing to create non-regular legacy sync path: ${filePath}`);
|
||||
}
|
||||
fs.writeFileSync(descriptor, content);
|
||||
fs.fchmodSync(descriptor, mode);
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function removeOpenedRegularFile(filePath, opened) {
|
||||
const quarantineDir = fs.mkdtempSync(path.join(path.dirname(filePath), '.ecc-remove-'));
|
||||
const quarantinePath = path.join(quarantineDir, path.basename(filePath));
|
||||
fs.renameSync(filePath, quarantinePath);
|
||||
const quarantined = openRegularFileNoFollow(quarantinePath);
|
||||
const openedStat = fs.fstatSync(opened.descriptor, { bigint: true });
|
||||
const quarantinedStat = fs.fstatSync(quarantined.descriptor, { bigint: true });
|
||||
fs.closeSync(quarantined.descriptor);
|
||||
fs.closeSync(opened.descriptor);
|
||||
opened.descriptor = null;
|
||||
if (quarantinedStat.dev !== openedStat.dev || quarantinedStat.ino !== openedStat.ino) {
|
||||
try {
|
||||
fs.linkSync(quarantinePath, filePath);
|
||||
fs.unlinkSync(quarantinePath);
|
||||
fs.rmdirSync(quarantineDir);
|
||||
} catch (_restoreError) {
|
||||
throw new Error(
|
||||
`Legacy sync path changed before removal; preserved replacement at ${quarantinePath}`
|
||||
);
|
||||
}
|
||||
throw new Error(`Legacy sync path changed before removal: ${filePath}`);
|
||||
}
|
||||
fs.unlinkSync(quarantinePath);
|
||||
fs.rmdirSync(quarantineDir);
|
||||
}
|
||||
|
||||
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 snapshot = readRegularFileNoFollow(statePath, 'utf8');
|
||||
if (!snapshot) throw new Error(`Legacy Codex sync state not found at ${statePath}`);
|
||||
return parseState(snapshot.content, statePath);
|
||||
}
|
||||
|
||||
function readStateIfPresent(statePath) {
|
||||
const snapshot = readRegularFileNoFollow(statePath, 'utf8');
|
||||
return snapshot ? parseState(snapshot.content, statePath) : null;
|
||||
}
|
||||
|
||||
function parseState(content, statePath) {
|
||||
const state = JSON.parse(content);
|
||||
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) {
|
||||
const snapshot = readRegularFileNoFollow(filePath);
|
||||
const previousType = snapshot ? 'file' : 'missing';
|
||||
return {
|
||||
path: filePath,
|
||||
installedSha256: null,
|
||||
previousType,
|
||||
previousContentBase64: snapshot ? snapshot.content.toString('base64') : null,
|
||||
previousMode: snapshot ? snapshot.mode : null,
|
||||
};
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
const snapshot = readRegularFileNoFollow(filePath);
|
||||
if (!entry.installedSha256) {
|
||||
if (snapshot) throw new Error(`Refusing to replace modified legacy Codex artifact: ${filePath}`);
|
||||
continue;
|
||||
}
|
||||
const digest = snapshot
|
||||
? crypto.createHash('sha256').update(snapshot.content).digest('hex')
|
||||
: null;
|
||||
if (digest !== 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 = readStateIfPresent(statePath);
|
||||
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;
|
||||
const snapshot = readRegularFileNoFollow(filePath, 'utf8');
|
||||
state.before[key] = snapshot ? snapshot.content : 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 opened = null;
|
||||
try {
|
||||
opened = openRegularFileNoFollow(filePath, true);
|
||||
} catch (_error) {
|
||||
retainedPaths.push(filePath);
|
||||
continue;
|
||||
}
|
||||
if (entry.previousType === 'file' && typeof entry.previousContentBase64 === 'string') {
|
||||
const previousContent = Buffer.from(entry.previousContentBase64, 'base64');
|
||||
const previousMode = entry.previousMode || 0o600;
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
||||
if (opened) {
|
||||
try {
|
||||
replaceOpenedRegularFile(opened, previousContent, previousMode);
|
||||
} finally {
|
||||
if (opened.descriptor !== null) fs.closeSync(opened.descriptor);
|
||||
}
|
||||
} else {
|
||||
createRegularFileNoFollow(filePath, previousContent, previousMode);
|
||||
}
|
||||
restoredPaths.push(filePath);
|
||||
} else if (entry.previousType === 'missing' || entry.previousType === undefined) {
|
||||
if (opened) {
|
||||
try {
|
||||
removeOpenedRegularFile(filePath, opened);
|
||||
} finally {
|
||||
if (opened.descriptor !== null) fs.closeSync(opened.descriptor);
|
||||
}
|
||||
}
|
||||
restoredPaths.push(filePath);
|
||||
} else {
|
||||
if (opened && opened.descriptor !== null) fs.closeSync(opened.descriptor);
|
||||
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 => {
|
||||
const trustedRoot = getTrustedRoot(state, path.resolve(entry.path));
|
||||
let installedSha256 = null;
|
||||
if (trustedRoot && !hasUnsafeManagedAncestor(entry.path, trustedRoot)) {
|
||||
try {
|
||||
const snapshot = readRegularFileNoFollow(entry.path);
|
||||
installedSha256 = snapshot
|
||||
? crypto.createHash('sha256').update(snapshot.content).digest('hex')
|
||||
: null;
|
||||
} catch (_error) {
|
||||
installedSha256 = null;
|
||||
}
|
||||
}
|
||||
return { ...entry, installedSha256 };
|
||||
});
|
||||
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 = readStateIfPresent(statePath);
|
||||
|
||||
if (!state) {
|
||||
let openedAgents = null;
|
||||
try {
|
||||
openedAgents = openRegularFileNoFollow(agentsPath, !dryRun);
|
||||
if (openedAgents) {
|
||||
const content = fs.readFileSync(openedAgents.descriptor, 'utf8');
|
||||
const stripped = stripMarkerBlock(content);
|
||||
if (stripped !== content) {
|
||||
plannedRemovals.push(`${agentsPath}#ecc-marker-block`);
|
||||
if (!dryRun) replaceOpenedRegularFile(openedAgents, stripped, openedAgents.stat.mode & 0o777);
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
retainedPaths.push(agentsPath);
|
||||
} finally {
|
||||
if (openedAgents) fs.closeSync(openedAgents.descriptor);
|
||||
}
|
||||
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;
|
||||
}
|
||||
let opened = null;
|
||||
try {
|
||||
opened = openRegularFileNoFollow(filePath, !dryRun);
|
||||
} catch (_error) {
|
||||
retainedPaths.push(filePath);
|
||||
continue;
|
||||
}
|
||||
if (!opened) continue;
|
||||
const currentContent = fs.readFileSync(opened.descriptor);
|
||||
const matches = entry.installedSha256
|
||||
? crypto.createHash('sha256').update(currentContent).digest('hex') === entry.installedSha256
|
||||
: false;
|
||||
if (!matches) {
|
||||
if (opened.descriptor !== null) fs.closeSync(opened.descriptor);
|
||||
retainedPaths.push(filePath);
|
||||
continue;
|
||||
}
|
||||
plannedRemovals.push(filePath);
|
||||
if (!dryRun) {
|
||||
if (entry.previousType === 'file' && typeof entry.previousContentBase64 === 'string') {
|
||||
replaceOpenedRegularFile(
|
||||
opened,
|
||||
Buffer.from(entry.previousContentBase64, 'base64'),
|
||||
entry.previousMode || 0o600
|
||||
);
|
||||
} else if (entry.previousType === 'missing' || entry.previousType === undefined) {
|
||||
removeOpenedRegularFile(filePath, opened);
|
||||
} else {
|
||||
if (opened.descriptor !== null) fs.closeSync(opened.descriptor);
|
||||
retainedPaths.push(filePath);
|
||||
continue;
|
||||
}
|
||||
removedPaths.push(filePath);
|
||||
}
|
||||
if (opened.descriptor !== null) fs.closeSync(opened.descriptor);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
@@ -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'],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -469,13 +529,46 @@ function removeContainedPath(destinationPath, trustedRoot, action, options = {})
|
||||
return null;
|
||||
}
|
||||
|
||||
const finalDestination = getManagedDestination(
|
||||
const managedDestination = getManagedDestination(
|
||||
existingDestination,
|
||||
trustedRoot,
|
||||
action,
|
||||
{ allowFinalSymlink: true }
|
||||
).managedPath;
|
||||
fs.rmSync(finalDestination, options);
|
||||
);
|
||||
const finalDestination = managedDestination.managedPath;
|
||||
const expectedStat = fs.lstatSync(finalDestination, { bigint: true });
|
||||
const quarantineDir = fs.mkdtempSync(path.join(
|
||||
path.dirname(managedDestination.canonicalRoot),
|
||||
'.ecc-remove-'
|
||||
));
|
||||
const quarantinePath = path.join(quarantineDir, path.basename(finalDestination));
|
||||
|
||||
try {
|
||||
fs.renameSync(finalDestination, quarantinePath);
|
||||
} catch (error) {
|
||||
fs.rmdirSync(quarantineDir);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const quarantinedStat = fs.lstatSync(quarantinePath, { bigint: true });
|
||||
if (!hasSameFileIdentity(expectedStat, quarantinedStat)) {
|
||||
try {
|
||||
fs.renameSync(quarantinePath, finalDestination);
|
||||
fs.rmdirSync(quarantineDir);
|
||||
} catch (_restoreError) {
|
||||
throw new Error(
|
||||
`Refusing to ${action}: managed destination changed before removal; replacement preserved at ${quarantinePath}.`
|
||||
);
|
||||
}
|
||||
throw createChangedDestinationError(action);
|
||||
}
|
||||
|
||||
if (quarantinedStat.isDirectory() && !options.recursive) {
|
||||
fs.rmdirSync(quarantinePath);
|
||||
} else {
|
||||
fs.rmSync(quarantinePath, options);
|
||||
}
|
||||
fs.rmdirSync(quarantineDir);
|
||||
return finalDestination;
|
||||
}
|
||||
|
||||
@@ -591,7 +684,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 +694,18 @@ function executeRepairOperation(repoRoot, operation, trustedRoot) {
|
||||
throw new Error(`Missing source file for repair: ${sourcePath || operation.sourceRelativePath}`);
|
||||
}
|
||||
|
||||
copyContainedFile(sourcePath, operation.destinationPath, trustedRoot, 'repair');
|
||||
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 +750,44 @@ 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',
|
||||
{ allowFinalSymlink: true }
|
||||
);
|
||||
if (!existingDestination) {
|
||||
return {
|
||||
removedPaths: [],
|
||||
cleanupTargets: []
|
||||
};
|
||||
}
|
||||
if (fs.lstatSync(existingDestination).isSymbolicLink()) {
|
||||
return {
|
||||
removedPaths: [],
|
||||
cleanupTargets: [],
|
||||
retainedPaths: [operation.destinationPath]
|
||||
};
|
||||
}
|
||||
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: [],
|
||||
retainedPaths: [operation.destinationPath]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const removedPath = removeContainedPath(
|
||||
operation.destinationPath,
|
||||
trustedRoot,
|
||||
@@ -794,7 +933,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 +1010,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 +1122,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 +1183,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 +1208,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 +1241,8 @@ function buildDiscoveryRecord(adapter, context) {
|
||||
installStatePath,
|
||||
exists: true,
|
||||
state,
|
||||
error: null
|
||||
error: null,
|
||||
legacy: Boolean(location)
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -1073,7 +1255,8 @@ function buildDiscoveryRecord(adapter, context) {
|
||||
installStatePath,
|
||||
exists: true,
|
||||
state: null,
|
||||
error: error.message
|
||||
error: error.message,
|
||||
legacy: Boolean(location)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1085,9 +1268,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 +1332,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 +1602,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 +1669,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 +1767,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 +1800,8 @@ function repairInstalledStates(options = {}) {
|
||||
const repairedPath = executeRepairOperation(
|
||||
context.repoRoot,
|
||||
operation,
|
||||
record.targetRoot
|
||||
record.targetRoot,
|
||||
repairLinkIndex
|
||||
);
|
||||
if (repairedPath) {
|
||||
repairedPaths.push(repairedPath);
|
||||
@@ -1576,7 +1820,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,
|
||||
@@ -1657,8 +1911,9 @@ function cleanupEmptyParentDirs(filePath, stopAt) {
|
||||
}
|
||||
|
||||
const finalPath = assertWithinTrustedRoot(validatedPath, trustedStopAt, 'clean up');
|
||||
fs.rmdirSync(finalPath);
|
||||
currentPath = path.dirname(finalPath);
|
||||
const removedPath = removeContainedPath(finalPath, trustedStopAt, 'clean up');
|
||||
if (!removedPath) break;
|
||||
currentPath = path.dirname(removedPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1682,8 +1937,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
|
||||
]));
|
||||
|
||||
@@ -1701,23 +1969,29 @@ function uninstallInstalledStates(options = {}) {
|
||||
try {
|
||||
const removedPaths = [];
|
||||
const cleanupTargets = [];
|
||||
const retainedPaths = [];
|
||||
const operations = getManagedOperations(state);
|
||||
|
||||
for (const operation of operations) {
|
||||
const outcome = executeUninstallOperation(operation, record.targetRoot);
|
||||
const outcome = executeUninstallOperation(operation, record.targetRoot, {
|
||||
preserveDriftedCopies: true,
|
||||
});
|
||||
removedPaths.push(...outcome.removedPaths);
|
||||
cleanupTargets.push(...outcome.cleanupTargets);
|
||||
retainedPaths.push(...(outcome.retainedPaths || []));
|
||||
}
|
||||
|
||||
const removedStatePath = removeContainedPath(
|
||||
record.installStatePath,
|
||||
record.targetRoot,
|
||||
'uninstall',
|
||||
{ force: true }
|
||||
);
|
||||
if (removedStatePath) {
|
||||
removedPaths.push(record.installStatePath);
|
||||
cleanupTargets.push(removedStatePath);
|
||||
if (retainedPaths.length === 0) {
|
||||
const removedStatePath = removeContainedPath(
|
||||
record.installStatePath,
|
||||
record.targetRoot,
|
||||
'uninstall',
|
||||
{ force: true }
|
||||
);
|
||||
if (removedStatePath) {
|
||||
removedPaths.push(record.installStatePath);
|
||||
cleanupTargets.push(removedStatePath);
|
||||
}
|
||||
}
|
||||
|
||||
for (const cleanupTarget of cleanupTargets) {
|
||||
@@ -1726,10 +2000,14 @@ function uninstallInstalledStates(options = {}) {
|
||||
|
||||
return {
|
||||
adapter: record.adapter,
|
||||
status: 'uninstalled',
|
||||
status: retainedPaths.length > 0 ? 'partial' : 'uninstalled',
|
||||
installStatePath: record.installStatePath,
|
||||
removedPaths,
|
||||
retainedPaths: [...new Set(retainedPaths)].sort(),
|
||||
plannedRemovals: [],
|
||||
warning: retainedPaths.length > 0
|
||||
? 'Modified or unverifiable managed files were preserved together with install-state for review.'
|
||||
: null,
|
||||
error: null
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -1749,12 +2027,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
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
'use strict';
|
||||
|
||||
const {
|
||||
createStateStore,
|
||||
projectInstallState,
|
||||
reconcileCurrentInstallState,
|
||||
} = require('./state-store');
|
||||
|
||||
function openFailure(error) {
|
||||
const warning = {
|
||||
code: 'projection-open-failed',
|
||||
message: error.message,
|
||||
};
|
||||
return {
|
||||
status: 'warning',
|
||||
warningCount: 1,
|
||||
warnings: [warning],
|
||||
warning,
|
||||
};
|
||||
}
|
||||
|
||||
async function withStateStore(options, operation) {
|
||||
const openStore = options.createStore || createStateStore;
|
||||
let store;
|
||||
try {
|
||||
store = await openStore({
|
||||
dbPath: options.dbPath,
|
||||
homeDir: options.homeDir,
|
||||
});
|
||||
return await 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,73 @@ module.exports = createInstallTargetAdapter({
|
||||
return paths
|
||||
.filter(supportsAntigravitySourcePath)
|
||||
.flatMap(sourceRelativePath => {
|
||||
if (sourceRelativePath === 'rules') {
|
||||
return createFlatRuleOperations({
|
||||
moduleId: module.id,
|
||||
repoRoot,
|
||||
sourceRelativePath,
|
||||
destinationDir: path.join(targetRoot, 'rules'),
|
||||
});
|
||||
}
|
||||
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
|
||||
|
||||
if (sourceRelativePath === 'commands') {
|
||||
return [
|
||||
createManagedScaffoldOperation(
|
||||
module.id,
|
||||
sourceRelativePath,
|
||||
path.join(targetRoot, 'workflows'),
|
||||
'preserve-relative-path'
|
||||
),
|
||||
];
|
||||
}
|
||||
if (
|
||||
normalizedSourcePath === 'rules'
|
||||
|| normalizedSourcePath.startsWith('rules/')
|
||||
) {
|
||||
return createFlatRuleOperations({
|
||||
moduleId: module.id,
|
||||
repoRoot,
|
||||
sourceRelativePath: normalizedSourcePath,
|
||||
destinationDir: path.join(targetRoot, 'rules'),
|
||||
});
|
||||
}
|
||||
|
||||
if (sourceRelativePath === 'agents') {
|
||||
return [
|
||||
createManagedScaffoldOperation(
|
||||
module.id,
|
||||
sourceRelativePath,
|
||||
path.join(targetRoot, 'skills'),
|
||||
'preserve-relative-path'
|
||||
),
|
||||
];
|
||||
}
|
||||
if (
|
||||
normalizedSourcePath === 'commands'
|
||||
|| normalizedSourcePath.startsWith('commands/')
|
||||
) {
|
||||
const commandRelativePath = normalizedSourcePath === 'commands'
|
||||
? ''
|
||||
: normalizedSourcePath.slice('commands/'.length);
|
||||
return [
|
||||
createManagedScaffoldOperation(
|
||||
module.id,
|
||||
normalizedSourcePath,
|
||||
path.join(targetRoot, 'workflows', commandRelativePath),
|
||||
'preserve-relative-path'
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return [adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput)];
|
||||
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,
|
||||
normalizedSourcePath,
|
||||
path.join(targetRoot, 'skills', skillRelativePath),
|
||||
'preserve-relative-path'
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
'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`);
|
||||
}
|
||||
|
||||
// Keep YAML loading behind the transform boundary. Public help commands load
|
||||
// the installer graph without executing a transform, including in hermetic
|
||||
// packed-artifact checks where runtime dependencies are intentionally absent.
|
||||
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 };
|
||||
if (Object.hasOwn(frontmatter, 'tools')) {
|
||||
adapted.tools = normalizeToolNames(frontmatter.tools);
|
||||
}
|
||||
if (Object.hasOwn(frontmatter, 'model')) {
|
||||
adapted.model = Object.hasOwn(MODEL_NAMES, frontmatter.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,
|
||||
};
|
||||
+186
-82
@@ -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.
|
||||
@@ -46,7 +58,9 @@ function readJsonObject(filePath, label) {
|
||||
try {
|
||||
parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse ${label} at ${filePath}: ${error.message}`);
|
||||
const wrappedError = new Error(`Failed to parse ${label} at ${filePath}: ${error.message}`);
|
||||
wrappedError.code = error.code;
|
||||
throw wrappedError;
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
@@ -56,21 +70,69 @@ function readJsonObject(filePath, label) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function stateWithContentDigests(state) {
|
||||
function readOptionalJsonObject(filePath, label) {
|
||||
try {
|
||||
return readJsonObject(filePath, label);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function readInstalledFileNoFollow(plan, operation) {
|
||||
assertSafeInstallOperation(plan, operation);
|
||||
assertSafeClaudeSkillOperation(plan, operation);
|
||||
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
|
||||
let descriptor;
|
||||
try {
|
||||
descriptor = fs.openSync(operation.destinationPath, flags);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const openedStat = fs.fstatSync(descriptor, { bigint: true });
|
||||
const finalPathStat = fs.lstatSync(operation.destinationPath, { bigint: true });
|
||||
if (finalPathStat.isSymbolicLink() || !finalPathStat.isFile()) {
|
||||
return null;
|
||||
}
|
||||
const identityMatches = openedStat.ino === finalPathStat.ino
|
||||
&& (!openedStat.dev || !finalPathStat.dev || openedStat.dev === finalPathStat.dev);
|
||||
if (!openedStat.isFile() || !identityMatches) {
|
||||
throw new Error(
|
||||
`Refusing to hash changed install destination: ${operation.destinationPath}`
|
||||
);
|
||||
}
|
||||
// Revalidate the full path after opening. The descriptor pins the file so
|
||||
// the digest and metadata refer to the same object.
|
||||
assertSafeInstallOperation(plan, operation);
|
||||
assertSafeClaudeSkillOperation(plan, operation);
|
||||
return fs.readFileSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function stateWithContentDigests(state, plan) {
|
||||
return {
|
||||
...state,
|
||||
operations: (state.operations || []).map(operation => {
|
||||
if (
|
||||
!operation.destinationPath
|
||||
|| !fs.existsSync(operation.destinationPath)
|
||||
|| !fs.statSync(operation.destinationPath).isFile()
|
||||
) {
|
||||
if (!operation.destinationPath) {
|
||||
return { ...operation };
|
||||
}
|
||||
const installedContent = readInstalledFileNoFollow(plan, operation);
|
||||
if (installedContent === null) {
|
||||
return { ...operation };
|
||||
}
|
||||
return {
|
||||
...operation,
|
||||
contentSha256: crypto.createHash('sha256')
|
||||
.update(fs.readFileSync(operation.destinationPath))
|
||||
.update(installedContent)
|
||||
.digest('hex'),
|
||||
};
|
||||
}),
|
||||
@@ -298,93 +360,134 @@ function applyInstallPlan(plan, dependencies = {}) {
|
||||
persistInstallState(plan.installStatePath, migration.bridgeState);
|
||||
}
|
||||
|
||||
for (const operation of appliedPlan.operations) {
|
||||
assertSafeInstallOperation(appliedPlan, operation);
|
||||
assertSafeClaudeSkillOperation(appliedPlan, operation);
|
||||
fs.mkdirSync(path.dirname(operation.destinationPath), { recursive: true });
|
||||
// Recheck directories that were absent during the first validation. This
|
||||
// narrows the symlink-swap window around mkdirSync, but path checks cannot
|
||||
// eliminate a later TOCTOU race before the file write.
|
||||
assertSafeInstallOperation(appliedPlan, operation);
|
||||
assertSafeClaudeSkillOperation(appliedPlan, operation);
|
||||
if (typeof beforeOperationWrite === 'function') {
|
||||
beforeOperationWrite({ plan: appliedPlan, operation });
|
||||
}
|
||||
|
||||
if (operation.kind === 'merge-json') {
|
||||
const payload = cloneJsonValue(operation.mergePayload);
|
||||
if (payload === undefined) {
|
||||
throw new Error(`Missing merge payload for ${operation.destinationPath}`);
|
||||
let finalState;
|
||||
try {
|
||||
for (const operation of appliedPlan.operations) {
|
||||
assertSafeInstallOperation(appliedPlan, operation);
|
||||
assertSafeClaudeSkillOperation(appliedPlan, operation);
|
||||
fs.mkdirSync(path.dirname(operation.destinationPath), { recursive: true });
|
||||
// Recheck directories that were absent during the first validation. This
|
||||
// narrows the symlink-swap window around mkdirSync, but path checks cannot
|
||||
// eliminate a later TOCTOU race before the file write.
|
||||
assertSafeInstallOperation(appliedPlan, operation);
|
||||
assertSafeClaudeSkillOperation(appliedPlan, operation);
|
||||
if (typeof beforeOperationWrite === 'function') {
|
||||
beforeOperationWrite({ plan: appliedPlan, operation });
|
||||
}
|
||||
|
||||
const filteredPayload = (
|
||||
isMcpConfigPath(operation.destinationPath) && disabledServers.length > 0
|
||||
)
|
||||
? filterMcpConfig(payload, disabledServers).config
|
||||
: payload;
|
||||
if (operation.kind === 'merge-json') {
|
||||
const payload = cloneJsonValue(operation.mergePayload);
|
||||
if (payload === undefined) {
|
||||
throw new Error(`Missing merge payload for ${operation.destinationPath}`);
|
||||
}
|
||||
|
||||
const currentValue = fs.existsSync(operation.destinationPath)
|
||||
? readJsonObject(operation.destinationPath, 'existing JSON config')
|
||||
: {};
|
||||
const mergedValue = deepMergeJson(currentValue, filteredPayload);
|
||||
fs.writeFileSync(operation.destinationPath, formatJson(mergedValue), 'utf8');
|
||||
continue;
|
||||
}
|
||||
const filteredPayload = (
|
||||
isMcpConfigPath(operation.destinationPath) && disabledServers.length > 0
|
||||
)
|
||||
? filterMcpConfig(payload, disabledServers).config
|
||||
: payload;
|
||||
|
||||
if (operation.kind === 'copy-file' && isMcpConfigPath(operation.destinationPath) && disabledServers.length > 0) {
|
||||
const sourceConfig = readJsonObject(operation.sourcePath, 'MCP config');
|
||||
const filteredConfig = filterMcpConfig(sourceConfig, disabledServers).config;
|
||||
fs.writeFileSync(operation.destinationPath, formatJson(filteredConfig), 'utf8');
|
||||
continue;
|
||||
}
|
||||
const currentValue = readOptionalJsonObject(
|
||||
operation.destinationPath,
|
||||
'existing JSON config'
|
||||
);
|
||||
const mergedValue = deepMergeJson(currentValue, filteredPayload);
|
||||
fs.writeFileSync(operation.destinationPath, formatJson(mergedValue), 'utf8');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Markdown may reference files whose installed paths move, such as rules
|
||||
// copied under rules/ecc. Rewrite only links that point at installed targets;
|
||||
// untouched links and non-markdown files stay on the byte-for-byte path.
|
||||
if (
|
||||
linkIndex
|
||||
&& operation.kind === 'copy-file'
|
||||
&& operation.sourceRelativePath
|
||||
&& isMarkdownPath(operation.destinationPath)
|
||||
) {
|
||||
const rewritten = rewriteRelativeLinks(
|
||||
fs.readFileSync(operation.sourcePath, 'utf8'),
|
||||
{ sourceRel: operation.sourceRelativePath, index: linkIndex }
|
||||
if (operation.kind === 'copy-file' && isMcpConfigPath(operation.destinationPath) && disabledServers.length > 0) {
|
||||
const sourceConfig = readJsonObject(operation.sourcePath, 'MCP config');
|
||||
const filteredConfig = filterMcpConfig(sourceConfig, disabledServers).config;
|
||||
fs.writeFileSync(operation.destinationPath, formatJson(filteredConfig), 'utf8');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Declared transforms are part of the install contract and always apply.
|
||||
// Markdown link rewriting is additive when the plan has a usable index.
|
||||
const needsLinkRewrite = Boolean(
|
||||
linkIndex
|
||||
&& operation.sourceRelativePath
|
||||
&& isMarkdownPath(operation.destinationPath)
|
||||
);
|
||||
fs.writeFileSync(operation.destinationPath, rewritten, 'utf8');
|
||||
continue;
|
||||
if (operation.kind === 'copy-file' && (operation.contentTransform || needsLinkRewrite)) {
|
||||
const transformed = transformInstallContent(
|
||||
operation,
|
||||
fs.readFileSync(operation.sourcePath, 'utf8')
|
||||
);
|
||||
const installedContent = needsLinkRewrite
|
||||
? rewriteRelativeLinks(transformed, {
|
||||
sourceRel: operation.sourceRelativePath,
|
||||
index: linkIndex,
|
||||
})
|
||||
: transformed;
|
||||
fs.writeFileSync(operation.destinationPath, installedContent, 'utf8');
|
||||
continue;
|
||||
}
|
||||
|
||||
fs.copyFileSync(operation.sourcePath, operation.destinationPath);
|
||||
}
|
||||
|
||||
fs.copyFileSync(operation.sourcePath, operation.destinationPath);
|
||||
}
|
||||
|
||||
if (resolvedClaudeHooksPlan) {
|
||||
assertSafeInstallOperation(appliedPlan, resolvedClaudeHooksPlan.hooksOperation);
|
||||
fs.mkdirSync(path.dirname(resolvedClaudeHooksPlan.hooksDestinationPath), { recursive: true });
|
||||
assertSafeInstallOperation(appliedPlan, resolvedClaudeHooksPlan.hooksOperation);
|
||||
if (typeof beforeOperationWrite === 'function') {
|
||||
beforeOperationWrite({ plan: appliedPlan, operation: resolvedClaudeHooksPlan.hooksOperation });
|
||||
if (resolvedClaudeHooksPlan) {
|
||||
assertSafeInstallOperation(appliedPlan, resolvedClaudeHooksPlan.hooksOperation);
|
||||
fs.mkdirSync(path.dirname(resolvedClaudeHooksPlan.hooksDestinationPath), { recursive: true });
|
||||
assertSafeInstallOperation(appliedPlan, resolvedClaudeHooksPlan.hooksOperation);
|
||||
if (typeof beforeOperationWrite === 'function') {
|
||||
beforeOperationWrite({ plan: appliedPlan, operation: resolvedClaudeHooksPlan.hooksOperation });
|
||||
}
|
||||
fs.writeFileSync(
|
||||
resolvedClaudeHooksPlan.hooksDestinationPath,
|
||||
JSON.stringify(resolvedClaudeHooksPlan.resolvedHooksConfig, null, 2) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
fs.writeFileSync(
|
||||
resolvedClaudeHooksPlan.hooksDestinationPath,
|
||||
JSON.stringify(resolvedClaudeHooksPlan.resolvedHooksConfig, null, 2) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
if (hasLegacyMigration) {
|
||||
removeLegacyClaudeSkillFiles(migration, plan.targetRoot);
|
||||
}
|
||||
if (hasLegacyMigration) {
|
||||
removeLegacyClaudeSkillFiles(migration, plan.targetRoot);
|
||||
}
|
||||
|
||||
if (shouldSetClaudeCommitAttributionPreference(appliedPlan)) {
|
||||
writeClaudeCommitAttributionPreference(path.join(plan.targetRoot, 'settings.json'));
|
||||
}
|
||||
if (shouldSetClaudeCommitAttributionPreference(appliedPlan)) {
|
||||
writeClaudeCommitAttributionPreference(path.join(plan.targetRoot, 'settings.json'));
|
||||
}
|
||||
|
||||
const finalState = stateWithContentDigests(migration.finalState);
|
||||
if (typeof beforeInstallStateWrite === 'function') {
|
||||
beforeInstallStateWrite({ plan: appliedPlan, state: finalState });
|
||||
finalState = stateWithContentDigests(migration.finalState, appliedPlan);
|
||||
if (typeof beforeInstallStateWrite === 'function') {
|
||||
beforeInstallStateWrite({ plan: appliedPlan, state: finalState });
|
||||
}
|
||||
persistInstallState(plan.installStatePath, finalState);
|
||||
} catch (error) {
|
||||
if (migration.requiresBridgeState) {
|
||||
try {
|
||||
// The bridge was committed before any writes. Refresh it with hashes of
|
||||
// files that now exist so uninstall can remove only bytes this attempt
|
||||
// actually installed while preserving user changes.
|
||||
persistInstallState(
|
||||
plan.installStatePath,
|
||||
stateWithContentDigests(migration.bridgeState, appliedPlan)
|
||||
);
|
||||
} catch (checkpointError) {
|
||||
throw new Error(
|
||||
`${error.message} Install-state checkpoint also failed: ${checkpointError.message}`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
let antigravityMigrationWarnings = [];
|
||||
try {
|
||||
const antigravityMigration = cleanupLegacyAntigravityInstall(appliedPlan);
|
||||
if (antigravityMigration.detected && !antigravityMigration.complete) {
|
||||
antigravityMigrationWarnings = [
|
||||
'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 : []),
|
||||
];
|
||||
}
|
||||
} catch (error) {
|
||||
antigravityMigrationWarnings = [
|
||||
`Legacy Antigravity cleanup did not finish: ${error.message}. Content under .agent was preserved; remove it manually or rerun the Antigravity install.`,
|
||||
];
|
||||
}
|
||||
persistInstallState(plan.installStatePath, finalState);
|
||||
|
||||
return {
|
||||
...plan,
|
||||
@@ -395,6 +498,7 @@ function applyInstallPlan(plan, dependencies = {}) {
|
||||
warnings: [
|
||||
...(Array.isArray(plan.warnings) ? plan.warnings : []),
|
||||
...migration.warnings,
|
||||
...antigravityMigrationWarnings,
|
||||
],
|
||||
applied: true,
|
||||
};
|
||||
|
||||
@@ -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 = {}) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
+65
-8
@@ -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 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,
|
||||
...store.getStatus({
|
||||
activeLimit: options.limit,
|
||||
recentSkillRunLimit: 20,
|
||||
pendingLimit: options.limit,
|
||||
workItemLimit: options.limit,
|
||||
}),
|
||||
...status,
|
||||
readiness: projectionWarningCount === 0
|
||||
? status.readiness
|
||||
: {
|
||||
...status.readiness,
|
||||
status: 'attention',
|
||||
attentionCount: status.readiness.attentionCount + projectionWarningCount,
|
||||
installProjectionWarnings: projectionWarningCount,
|
||||
},
|
||||
installStateProjection,
|
||||
};
|
||||
payload.githubCoordination = summarizeGithubCoordination(payload.workItems);
|
||||
|
||||
|
||||
@@ -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}"
|
||||
)"
|
||||
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
|
||||
|
||||
record_managed_path "$CONFIG_FILE"
|
||||
record_managed_path "$AGENTS_FILE"
|
||||
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
|
||||
|
||||
+55
-11
@@ -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({
|
||||
homeDir: process.env.HOME || os.homedir(),
|
||||
projectRoot: process.cwd(),
|
||||
targets: options.targets,
|
||||
dryRun: options.dryRun,
|
||||
});
|
||||
const hasErrors = result.summary.errorCount > 0;
|
||||
if (options.legacyCodexSync && options.targets.length > 0) {
|
||||
throw new Error('--legacy-codex-sync cannot be combined with --target');
|
||||
}
|
||||
const result = options.legacyCodexSync
|
||||
? uninstallLegacyCodexSync({
|
||||
codexHome: process.env.CODEX_HOME,
|
||||
dryRun: options.dryRun,
|
||||
})
|
||||
: uninstallInstalledStates({
|
||||
homeDir: process.env.HOME || os.homedir(),
|
||||
projectRoot: process.cwd(),
|
||||
targets: options.targets,
|
||||
dryRun: options.dryRun,
|
||||
});
|
||||
if (!options.dryRun && !options.legacyCodexSync) {
|
||||
const { reconcileCanonicalInstallStates } = require('./lib/install-state-store-sync');
|
||||
result.installStateProjection = await reconcileCanonicalInstallStates({
|
||||
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
@@ -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 |
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
#!/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 {
|
||||
try {
|
||||
fs.rmSync(tempRoot, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 10,
|
||||
retryDelay: 100,
|
||||
});
|
||||
} catch (cleanupError) {
|
||||
process.stderr.write(
|
||||
`Could not remove lifecycle temp root ${tempRoot}: ${cleanupError.message}\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
'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')));
|
||||
});
|
||||
|
||||
test('lifecycle cleanup retries Windows file locks without masking results', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.join(__dirname, 'packed-artifact-lifecycle.js'),
|
||||
'utf8'
|
||||
);
|
||||
assert.match(source, /maxRetries:\s*10/);
|
||||
assert.match(source, /retryDelay:\s*100/);
|
||||
assert.match(source, /Could not remove lifecycle temp root/);
|
||||
});
|
||||
|
||||
console.log(`\nPassed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,166 @@
|
||||
'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/);
|
||||
});
|
||||
|
||||
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 \}\}/);
|
||||
assert.match(verify, /tests\/ci\/packed-artifact-lifecycle\.js/);
|
||||
});
|
||||
|
||||
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 release-artifacts\/tests\/ci\/packed-artifact-lifecycle\.js/);
|
||||
assert.doesNotMatch(lifecycle, /actions\/checkout@/);
|
||||
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,698 @@
|
||||
/**
|
||||
* 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++;
|
||||
|
||||
if (test('applies a declared Antigravity transform without link-index metadata', () => {
|
||||
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'antigravity-transform-only-'));
|
||||
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: null,
|
||||
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, []),
|
||||
};
|
||||
|
||||
applyInstallPlan(plan, { writeInstallState() {} });
|
||||
|
||||
const installedContent = fs.readFileSync(operation.destinationPath, 'utf8');
|
||||
assert.notStrictEqual(installedContent, fs.readFileSync(sourcePath, 'utf8'));
|
||||
assert.ok(!installedContent.includes('color:'));
|
||||
} finally {
|
||||
fs.rmSync(projectRoot, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,528 @@
|
||||
'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 existingDescriptor = fs.openSync(existingPath, 'r+');
|
||||
const originalMode = fs.fstatSync(existingDescriptor).mode & 0o777;
|
||||
|
||||
const statePath = beginLegacySyncState({
|
||||
codexHome,
|
||||
backupDir,
|
||||
previousHooksPath: '/tmp/user-hooks',
|
||||
installedHooksPath: path.join(codexHome, 'git-hooks'),
|
||||
});
|
||||
recordLegacySyncPath({ statePath, filePath: existingPath });
|
||||
recordLegacySyncPath({ statePath, filePath: createdPath });
|
||||
const partialContent = Buffer.from('# Partial ECC write\n');
|
||||
fs.ftruncateSync(existingDescriptor, 0);
|
||||
fs.writeSync(existingDescriptor, partialContent, 0, partialContent.length, 0);
|
||||
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');
|
||||
const restoredContent = Buffer.alloc(Buffer.byteLength('# User prompt\n'));
|
||||
fs.readSync(existingDescriptor, restoredContent, 0, restoredContent.length, 0);
|
||||
assert.strictEqual(restoredContent.toString('utf8'), '# User prompt\n');
|
||||
assert.strictEqual(fs.fstatSync(existingDescriptor).mode & 0o777, originalMode);
|
||||
fs.closeSync(existingDescriptor);
|
||||
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();
|
||||
@@ -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);
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
@@ -166,7 +168,7 @@ function withTemporarilyMovedPath(filePath, callback) {
|
||||
}
|
||||
|
||||
function managedOperation(kind, destinationPath, overrides = {}) {
|
||||
return {
|
||||
const operation = {
|
||||
kind,
|
||||
moduleId: 'test-module',
|
||||
sourceRelativePath: 'rules/common/coding-style.md',
|
||||
@@ -176,6 +178,41 @@ function managedOperation(kind, destinationPath, overrides = {}) {
|
||||
scaffoldOnly: false,
|
||||
...overrides,
|
||||
};
|
||||
if (
|
||||
kind === 'copy-file'
|
||||
&& !Object.prototype.hasOwnProperty.call(overrides, 'contentSha256')
|
||||
) {
|
||||
let descriptor;
|
||||
try {
|
||||
descriptor = fs.openSync(
|
||||
destinationPath,
|
||||
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0)
|
||||
);
|
||||
const openedStat = fs.fstatSync(descriptor, { bigint: true });
|
||||
const finalPathStat = fs.lstatSync(destinationPath, { bigint: true });
|
||||
const identityMatches = openedStat.ino === finalPathStat.ino
|
||||
&& (!openedStat.dev || !finalPathStat.dev || openedStat.dev === finalPathStat.dev);
|
||||
if (
|
||||
openedStat.isFile()
|
||||
&& finalPathStat.isFile()
|
||||
&& !finalPathStat.isSymbolicLink()
|
||||
&& identityMatches
|
||||
) {
|
||||
operation.contentSha256 = crypto.createHash('sha256')
|
||||
.update(fs.readFileSync(descriptor))
|
||||
.digest('hex');
|
||||
}
|
||||
} catch (error) {
|
||||
if (!['ENOENT', 'ELOOP'].includes(error.code)) {
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
if (descriptor !== undefined) {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
}
|
||||
return operation;
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
@@ -184,6 +221,25 @@ function runTests() {
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('managed-operation digest never follows a final symlink', () => {
|
||||
const tempDir = createTempDir('install-lifecycle-symlink-digest-');
|
||||
const victimPath = path.join(tempDir, 'victim.md');
|
||||
const symlinkPath = path.join(tempDir, 'managed.md');
|
||||
try {
|
||||
fs.writeFileSync(victimPath, 'user content\n');
|
||||
try {
|
||||
fs.symlinkSync(victimPath, symlinkPath, 'file');
|
||||
} catch {
|
||||
console.log(' (file symlink unsupported on this platform; skipping)');
|
||||
return;
|
||||
}
|
||||
const operation = managedOperation('copy-file', symlinkPath);
|
||||
assert.strictEqual(operation.contentSha256, undefined);
|
||||
} finally {
|
||||
cleanup(tempDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('normalizes default targets and dedupes adapter aliases', () => {
|
||||
const defaultTargets = normalizeTargets();
|
||||
|
||||
@@ -635,6 +691,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 +1569,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-');
|
||||
@@ -1900,14 +2143,18 @@ function runTests() {
|
||||
canonicalDestinationPath = fs.realpathSync(destinationPath);
|
||||
writeCursorState(projectRoot, {
|
||||
operations: [
|
||||
managedOperation('copy-file', destinationPath, { strategy: 'copy-file' }),
|
||||
managedOperation('copy-file', destinationPath, {
|
||||
strategy: 'copy-file',
|
||||
contentSha256: '0'.repeat(64),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
fs.openSync = function openSyncWithLateParentSwap(filePath, flags, mode) {
|
||||
const isDestinationWrite = path.resolve(filePath) === canonicalDestinationPath
|
||||
const writeFlags = fs.constants.O_WRONLY | fs.constants.O_RDWR;
|
||||
const isDestinationWrite = path.resolve(String(filePath)) === canonicalDestinationPath
|
||||
&& typeof flags === 'number'
|
||||
&& (flags & fs.constants.O_WRONLY) === fs.constants.O_WRONLY;
|
||||
&& (flags & writeFlags) !== 0;
|
||||
if (!insertedSymlink && isDestinationWrite) {
|
||||
fs.renameSync(destinationParent, backupParent);
|
||||
fs.symlinkSync(
|
||||
@@ -2289,7 +2536,7 @@ function runTests() {
|
||||
targets: ['cursor'],
|
||||
});
|
||||
|
||||
assert.strictEqual(result.results[0].status, 'uninstalled');
|
||||
assert.strictEqual(result.results[0].status, 'uninstalled', result.results[0].error);
|
||||
assert.ok(result.results[0].removedPaths.includes(destinationPath));
|
||||
assert.ok(!fs.existsSync(destinationPath));
|
||||
assert.ok(!fs.existsSync(path.dirname(destinationPath)));
|
||||
@@ -2300,6 +2547,40 @@ function runTests() {
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('uninstall preserves drifted canonical copied files and install-state', () => {
|
||||
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', 'managed.md');
|
||||
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
|
||||
fs.writeFileSync(destinationPath, 'managed\n');
|
||||
const operation = managedOperation('copy-file', destinationPath, {
|
||||
strategy: 'copy-file',
|
||||
});
|
||||
const { installStatePath } = writeCursorState(projectRoot, {
|
||||
request: { legacyMode: false, legacyLanguages: [] },
|
||||
operations: [operation],
|
||||
});
|
||||
fs.appendFileSync(destinationPath, 'user edit\n');
|
||||
|
||||
const result = uninstallInstalledStates({
|
||||
homeDir,
|
||||
projectRoot,
|
||||
targets: ['cursor'],
|
||||
});
|
||||
|
||||
assert.strictEqual(result.results[0].status, 'partial');
|
||||
assert.ok(result.results[0].retainedPaths.includes(destinationPath));
|
||||
assert.strictEqual(fs.readFileSync(destinationPath, 'utf8'), 'managed\nuser edit\n');
|
||||
assert.ok(fs.existsSync(installStatePath));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('uninstall cleanup stops at the adapter-derived target root', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const cleanupBoundaryRoot = createTempDir('install-lifecycle-boundary-');
|
||||
@@ -2541,7 +2822,7 @@ function runTests() {
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('uninstall removes an in-root final symlink without deleting its victim', () => {
|
||||
if (test('uninstall preserves a managed path replaced by a symlink and its victim', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
|
||||
@@ -2569,8 +2850,9 @@ function runTests() {
|
||||
targets: ['cursor'],
|
||||
});
|
||||
|
||||
assert.strictEqual(result.results[0].status, 'uninstalled');
|
||||
assert.ok(!fs.existsSync(destinationPath));
|
||||
assert.strictEqual(result.results[0].status, 'partial');
|
||||
assert.ok(fs.lstatSync(destinationPath).isSymbolicLink());
|
||||
assert.ok(result.results[0].retainedPaths.includes(destinationPath));
|
||||
assert.strictEqual(fs.readFileSync(victimPath, 'utf8'), 'victim sentinel\n');
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
@@ -2642,6 +2924,66 @@ function runTests() {
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('uninstall quarantine prevents an ancestor swap from deleting outside-root content', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
const outsideRoot = createTempDir('install-lifecycle-outside-');
|
||||
const targetRoot = path.join(projectRoot, '.cursor');
|
||||
const destinationParent = path.join(targetRoot, 'swap-parent');
|
||||
const backupParent = path.join(targetRoot, 'swap-parent-backup');
|
||||
const destinationPath = path.join(destinationParent, 'managed.md');
|
||||
const outsideDestinationPath = path.join(outsideRoot, 'managed.md');
|
||||
const originalRenameSync = fs.renameSync;
|
||||
let swapped = false;
|
||||
let result;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(destinationParent, { recursive: true });
|
||||
fs.writeFileSync(destinationPath, 'managed\n');
|
||||
fs.writeFileSync(outsideDestinationPath, 'outside sentinel\n');
|
||||
writeCursorState(projectRoot, {
|
||||
operations: [managedOperation('copy-file', destinationPath)],
|
||||
});
|
||||
|
||||
fs.renameSync = function renameSyncWithAncestorSwap(sourcePath, targetPath) {
|
||||
if (
|
||||
!swapped
|
||||
&& path.basename(sourcePath) === path.basename(destinationPath)
|
||||
&& path.basename(path.dirname(targetPath)).startsWith('.ecc-remove-')
|
||||
) {
|
||||
originalRenameSync.call(fs, destinationParent, backupParent);
|
||||
fs.symlinkSync(
|
||||
outsideRoot,
|
||||
destinationParent,
|
||||
process.platform === 'win32' ? 'junction' : 'dir'
|
||||
);
|
||||
swapped = true;
|
||||
}
|
||||
return originalRenameSync.call(fs, sourcePath, targetPath);
|
||||
};
|
||||
|
||||
result = uninstallInstalledStates({
|
||||
homeDir,
|
||||
projectRoot,
|
||||
targets: ['cursor'],
|
||||
});
|
||||
} finally {
|
||||
fs.renameSync = originalRenameSync;
|
||||
}
|
||||
|
||||
try {
|
||||
assert.strictEqual(swapped, true);
|
||||
assert.strictEqual(result.results[0].status, 'error');
|
||||
assert.match(result.results[0].error, /changed during|changed before removal/);
|
||||
assert.strictEqual(fs.readFileSync(outsideDestinationPath, 'utf8'), 'outside sentinel\n');
|
||||
assert.strictEqual(fs.readFileSync(path.join(backupParent, 'managed.md'), 'utf8'), 'managed\n');
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
cleanup(outsideRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('uninstall restores previous JSON snapshots for template and remove operations', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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();
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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*\}\}/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -104,6 +104,28 @@ 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"'));
|
||||
assert.ok(
|
||||
source.indexOf("trap 'rollback_legacy_sync $?' ERR")
|
||||
< source.indexOf('record_managed_path "$CONFIG_FILE"'),
|
||||
'rollback trap must be active before the first ownership record'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
@@ -165,6 +166,7 @@ function runTests() {
|
||||
strategy: 'preserve-relative-path',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
contentSha256: crypto.createHash('sha256').update('managed\n').digest('hex'),
|
||||
},
|
||||
{
|
||||
kind: 'merge-json',
|
||||
@@ -282,6 +284,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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user