mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-06 17:57:52 +02:00
fix(release): make ECC 2.2 ready to publish
This commit is contained in:
@@ -24,6 +24,16 @@ jobs:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Require the release commit to equal origin main
|
||||
run: |
|
||||
git fetch origin main --no-tags
|
||||
RELEASE_COMMIT=$(git rev-parse HEAD)
|
||||
MAIN_COMMIT=$(git rev-parse origin/main)
|
||||
if [ "$RELEASE_COMMIT" != "$MAIN_COMMIT" ]; then
|
||||
echo "::error::The release commit must equal origin/main exactly"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
@@ -69,43 +79,29 @@ jobs:
|
||||
PACKAGE_NAME=$(node -p "require('./package.json').name")
|
||||
PACKAGE_VERSION=$(node -p "require('./package.json').version")
|
||||
NPM_DIST_TAG=$(node -p "require('./package.json').version.includes('-') ? 'next' : 'latest'")
|
||||
if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
|
||||
set +e
|
||||
NPM_LOOKUP=$(npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version 2>&1)
|
||||
NPM_STATUS=$?
|
||||
set -e
|
||||
if [ "$NPM_STATUS" -eq 0 ]; then
|
||||
echo "already_published=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
elif printf '%s\n' "$NPM_LOOKUP" | grep -q 'E404'; then
|
||||
echo "already_published=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "::error::npm registry lookup failed; refusing to infer that the version is unpublished"
|
||||
printf '%s\n' "$NPM_LOOKUP"
|
||||
exit "$NPM_STATUS"
|
||||
fi
|
||||
echo "dist_tag=${NPM_DIST_TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Generate release highlights
|
||||
id: highlights
|
||||
env:
|
||||
TAG_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
TAG_VERSION="${TAG_NAME#v}"
|
||||
cat > release_body.md <<EOF
|
||||
## ECC ${TAG_VERSION}
|
||||
|
||||
### What This Release Focuses On
|
||||
- Harness reliability and hook stability across Claude Code, Cursor, OpenCode, and Codex
|
||||
- Stronger eval-driven workflows and quality gates
|
||||
- Better operator UX for autonomous loop execution
|
||||
|
||||
### Notable Changes
|
||||
- Session persistence and hook lifecycle fixes
|
||||
- Expanded skills and command coverage for harness performance work
|
||||
- Improved release-note generation and changelog hygiene
|
||||
|
||||
### Notes
|
||||
- npm package: \`ecc-universal\`
|
||||
- Claude marketplace/plugin identifier: \`ecc@ecc\`
|
||||
- For migration tips and compatibility notes, see README and CHANGELOG.
|
||||
EOF
|
||||
- name: Use reviewed release notes
|
||||
run: cp docs/releases/2.2.0/RELEASE_NOTES.md release_body.md
|
||||
|
||||
- name: Pack npm artifact
|
||||
id: pack
|
||||
run: |
|
||||
npm pack --json > npm-pack.json
|
||||
node -e "const crypto = require('crypto'); const fs = require('fs'); const data = JSON.parse(fs.readFileSync('npm-pack.json', 'utf8')); const file = data[0]?.filename; if (!/^ecc-universal-[0-9A-Za-z.+-]+\.tgz$/.test(file || '')) throw new Error('Unexpected packed filename'); const archives = fs.readdirSync('.').filter(name => name.endsWith('.tgz')); if (archives.length !== 1 || archives[0] !== file) throw new Error('Expected exactly one packed archive'); const digest = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); fs.appendFileSync(process.env.GITHUB_OUTPUT, 'package_file=' + file + '\npackage_sha256=' + digest + '\n')"
|
||||
node -e "const crypto = require('crypto'); const fs = require('fs'); const data = JSON.parse(fs.readFileSync('npm-pack.json', 'utf8')); const entries = Array.isArray(data) ? data : [data]; const file = entries.find(entry => /^ecc-universal-[0-9A-Za-z.+-]+\.tgz$/.test(entry?.filename || ''))?.filename; if (!file) throw new Error('Unexpected packed filename'); const archives = fs.readdirSync('.').filter(name => name.endsWith('.tgz')); if (archives.length !== 1 || archives[0] !== file) throw new Error('Expected exactly one packed archive'); const digest = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); fs.appendFileSync(process.env.GITHUB_OUTPUT, 'package_file=' + file + '\npackage_sha256=' + digest + '\n')"
|
||||
|
||||
- name: Upload release artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
@@ -182,14 +178,6 @@ jobs:
|
||||
ECC_RELEASE_SHA256: ${{ needs.verify.outputs.package_sha256 }}
|
||||
run: node -e "const crypto = require('crypto'); const fs = require('fs'); const file = process.env.ECC_RELEASE_PACKAGE; const expected = process.env.ECC_RELEASE_SHA256; if (!/^ecc-universal-[0-9A-Za-z.+-]+\.tgz$/.test(file || '')) throw new Error('Unexpected packed filename'); if (!/^[a-f0-9]{64}$/.test(expected || '')) throw new Error('Invalid packed SHA-256'); const archives = fs.readdirSync('.').filter(name => name.endsWith('.tgz')); if (archives.length !== 1 || archives[0] !== file) throw new Error('Expected exactly one downloaded archive'); const actual = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); if (actual !== expected) throw new Error('Downloaded publish artifact SHA-256 mismatch')"
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
|
||||
with:
|
||||
body_path: release_body.md
|
||||
generate_release_notes: true
|
||||
prerelease: ${{ contains(github.ref_name, '-') }}
|
||||
make_latest: ${{ contains(github.ref_name, '-') && 'false' || 'true' }}
|
||||
|
||||
- name: Publish npm package
|
||||
if: needs.verify.outputs.already_published != 'true'
|
||||
env:
|
||||
@@ -197,3 +185,11 @@ jobs:
|
||||
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}"
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
|
||||
with:
|
||||
body_path: release_body.md
|
||||
generate_release_notes: true
|
||||
prerelease: ${{ contains(github.ref_name, '-') }}
|
||||
make_latest: ${{ contains(github.ref_name, '-') && 'false' || 'true' }}
|
||||
|
||||
@@ -48,6 +48,16 @@ jobs:
|
||||
ref: refs/tags/${{ inputs.tag }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Require the release commit to equal origin main
|
||||
run: |
|
||||
git fetch origin main --no-tags
|
||||
RELEASE_COMMIT=$(git rev-parse HEAD)
|
||||
MAIN_COMMIT=$(git rev-parse origin/main)
|
||||
if [ "$RELEASE_COMMIT" != "$MAIN_COMMIT" ]; then
|
||||
echo "::error::The release commit must equal origin/main exactly"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
@@ -93,36 +103,29 @@ jobs:
|
||||
PACKAGE_NAME=$(node -p "require('./package.json').name")
|
||||
PACKAGE_VERSION=$(node -p "require('./package.json').version")
|
||||
NPM_DIST_TAG=$(node -p "require('./package.json').version.includes('-') ? 'next' : 'latest'")
|
||||
if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
|
||||
set +e
|
||||
NPM_LOOKUP=$(npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version 2>&1)
|
||||
NPM_STATUS=$?
|
||||
set -e
|
||||
if [ "$NPM_STATUS" -eq 0 ]; then
|
||||
echo "already_published=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
elif printf '%s\n' "$NPM_LOOKUP" | grep -q 'E404'; then
|
||||
echo "already_published=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "::error::npm registry lookup failed; refusing to infer that the version is unpublished"
|
||||
printf '%s\n' "$NPM_LOOKUP"
|
||||
exit "$NPM_STATUS"
|
||||
fi
|
||||
echo "dist_tag=${NPM_DIST_TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Generate release highlights
|
||||
env:
|
||||
TAG_NAME: ${{ inputs.tag }}
|
||||
run: |
|
||||
TAG_VERSION="${TAG_NAME#v}"
|
||||
cat > release_body.md <<EOF
|
||||
## ECC ${TAG_VERSION}
|
||||
|
||||
### What This Release Focuses On
|
||||
- Harness reliability and cross-platform compatibility
|
||||
- Eval-driven quality improvements
|
||||
- Better workflow and operator ergonomics
|
||||
|
||||
### Package Notes
|
||||
- npm package: \`ecc-universal\`
|
||||
- Claude marketplace/plugin identifier: \`ecc@ecc\`
|
||||
EOF
|
||||
- name: Use reviewed release notes
|
||||
run: cp docs/releases/2.2.0/RELEASE_NOTES.md release_body.md
|
||||
|
||||
- name: Pack npm artifact
|
||||
id: pack
|
||||
run: |
|
||||
npm pack --json > npm-pack.json
|
||||
node -e "const crypto = require('crypto'); const fs = require('fs'); const data = JSON.parse(fs.readFileSync('npm-pack.json', 'utf8')); const file = data[0]?.filename; if (!/^ecc-universal-[0-9A-Za-z.+-]+\.tgz$/.test(file || '')) throw new Error('Unexpected packed filename'); const archives = fs.readdirSync('.').filter(name => name.endsWith('.tgz')); if (archives.length !== 1 || archives[0] !== file) throw new Error('Expected exactly one packed archive'); const digest = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); fs.appendFileSync(process.env.GITHUB_OUTPUT, 'package_file=' + file + '\npackage_sha256=' + digest + '\n')"
|
||||
node -e "const crypto = require('crypto'); const fs = require('fs'); const data = JSON.parse(fs.readFileSync('npm-pack.json', 'utf8')); const entries = Array.isArray(data) ? data : [data]; const file = entries.find(entry => /^ecc-universal-[0-9A-Za-z.+-]+\.tgz$/.test(entry?.filename || ''))?.filename; if (!file) throw new Error('Unexpected packed filename'); const archives = fs.readdirSync('.').filter(name => name.endsWith('.tgz')); if (archives.length !== 1 || archives[0] !== file) throw new Error('Expected exactly one packed archive'); const digest = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); fs.appendFileSync(process.env.GITHUB_OUTPUT, 'package_file=' + file + '\npackage_sha256=' + digest + '\n')"
|
||||
|
||||
- name: Upload release artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
@@ -199,6 +202,14 @@ jobs:
|
||||
ECC_RELEASE_SHA256: ${{ needs.verify.outputs.package_sha256 }}
|
||||
run: node -e "const crypto = require('crypto'); const fs = require('fs'); const file = process.env.ECC_RELEASE_PACKAGE; const expected = process.env.ECC_RELEASE_SHA256; if (!/^ecc-universal-[0-9A-Za-z.+-]+\.tgz$/.test(file || '')) throw new Error('Unexpected packed filename'); if (!/^[a-f0-9]{64}$/.test(expected || '')) throw new Error('Invalid packed SHA-256'); const archives = fs.readdirSync('.').filter(name => name.endsWith('.tgz')); if (archives.length !== 1 || archives[0] !== file) throw new Error('Expected exactly one downloaded archive'); const actual = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); if (actual !== expected) throw new Error('Downloaded publish artifact SHA-256 mismatch')"
|
||||
|
||||
- name: Publish npm package
|
||||
if: needs.verify.outputs.already_published != 'true'
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
ECC_RELEASE_PACKAGE: ${{ needs.verify.outputs.package_file }}
|
||||
NPM_DIST_TAG: ${{ needs.verify.outputs.dist_tag }}
|
||||
run: npm publish "./${ECC_RELEASE_PACKAGE}" --access public --provenance --tag "${NPM_DIST_TAG}"
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
|
||||
with:
|
||||
@@ -207,11 +218,3 @@ jobs:
|
||||
generate_release_notes: ${{ inputs.generate-notes }}
|
||||
prerelease: ${{ contains(inputs.tag, '-') }}
|
||||
make_latest: ${{ contains(inputs.tag, '-') && 'false' || 'true' }}
|
||||
|
||||
- name: Publish npm package
|
||||
if: needs.verify.outputs.already_published != 'true'
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
ECC_RELEASE_PACKAGE: ${{ needs.verify.outputs.package_file }}
|
||||
NPM_DIST_TAG: ${{ needs.verify.outputs.dist_tag }}
|
||||
run: npm publish "./${ECC_RELEASE_PACKAGE}" --access public --provenance --tag "${NPM_DIST_TAG}"
|
||||
|
||||
@@ -2,13 +2,33 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 2.2.0 - 2026-08-25
|
||||
|
||||
### Added
|
||||
|
||||
- Guided, manifest-driven setup across supported harnesses, with exact install-state ownership, health checks, repair, and uninstall workflows.
|
||||
- Native Antigravity 2.0 installation under `.agents/`, including rules, workflows, skills, and adapted agents, plus a cross-platform installation guide.
|
||||
- New workflow and operator capabilities including the Itô skill family, Nasiko integration, multi-model council review, dev-team collaboration, agent evaluation, living-docs governance, secure terminal opening, and TasteForge multimodal workflows.
|
||||
- A thin Pi adapter and expanded cross-harness support, release artifact lifecycle testing, Docker-based CLI testing, and stronger Python validation.
|
||||
|
||||
### Changed
|
||||
|
||||
- Default MCP connector set reduced to a single connector (`chrome-devtools`) per the new connector policy (`docs/MCP-CONNECTOR-POLICY.md`). The six previous defaults (`github`, `context7`, `exa`, `memory`, `playwright`, `sequential-thinking`) were retired after the June 2026 audit: their jobs are covered by skills wrapping CLIs/REST APIs (`github-ops`, `documentation-lookup`, `exa-search`, e2e skills) or by harness-native features (memory, extended thinking, web search). All six remain opt-in via `mcp-configs/mcp-servers.json`.
|
||||
- OpenCode home installs now use its canonical `~/.config/opencode` location, and bundled agents inherit the model selected by the user instead of pinning an Anthropic provider.
|
||||
- `skill-comply` is now part of the install manifest and npm distribution, with generated Python caches excluded from both install and package surfaces.
|
||||
- Release automation now verifies the tag is exactly on `origin/main`, fails closed on npm registry errors, tests the exact packed artifact across Linux, macOS, and Windows, publishes npm before creating the GitHub Release, and uses reviewed release notes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `ecc memory` writes and `--body-file` reads failed on Windows under Node 22.12-22.16 and 24.0-24.1. libuv resolved path-based `stat()`/`lstat()` through `GetFileInformationByName` without setting the volume serial, while `fstat()` reported it, so the memory vault's TOCTOU guard rejected every operation. Fixed upstream in libuv 1.51.0; the guard no longer depends on the runtime's patch level. The guard's stat calls now request `BigInt` values, so Windows file IDs past `Number.MAX_SAFE_INTEGER` can no longer collapse two distinct files into one identity.
|
||||
- Selective reinstall now merges the prior ownership ledger, so later module additions do not orphan files from earlier installs and uninstall removes the complete managed surface.
|
||||
- Legacy Codex sync uninstall now uses ownership evidence, preserves user files, and requires an explicit opt-in for weaker marker-only cleanup.
|
||||
- Hook, plan-canvas, session, memory, observer, skill-evolution, Discord delivery, and Windows compatibility regressions fixed across the runtime.
|
||||
|
||||
### Release audit
|
||||
|
||||
- Audited the complete delta from `v2.1.0`: 108 commits across 530 files, with 40,299 insertions and 4,679 deletions on the pre-release baseline.
|
||||
- The release gate installs and exercises the exact npm archive, including cumulative ownership, doctor, drift detection, repair, uninstall, and user-file preservation.
|
||||
|
||||
## 2.0.0 - 2026-06-09
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# ECC 2.2.0
|
||||
|
||||
ECC 2.2.0 makes the universal installer a first-class, cross-harness distribution path. It adds native Antigravity 2.0 support, repairs cumulative install ownership, aligns OpenCode with its canonical configuration directory, and strengthens the exact-artifact release gate.
|
||||
|
||||
## Installer and harness reliability
|
||||
|
||||
- Antigravity installs natively to `.agents/{rules,workflows,skills,agents}`. Do not manually rename a legacy `.agent` directory. Re-run ECC 2.2.0 so the installer can apply its ownership-aware migration rules.
|
||||
- Repeated selective installs retain the complete managed ownership ledger. A later module install no longer causes previously installed ECC files to survive uninstall.
|
||||
- OpenCode home installs use `~/.config/opencode`, and its bundled agent definitions inherit the user's selected model provider.
|
||||
- Legacy Codex sync cleanup requires ownership evidence by default and preserves untracked or modified user files.
|
||||
- `skill-comply` is included in both the install graph and npm archive. Python bytecode and pytest caches remain excluded.
|
||||
|
||||
## New capabilities
|
||||
|
||||
- Guided multi-harness setup and stronger doctor, repair, status, and uninstall flows.
|
||||
- Native Antigravity 2.0 documentation for Bash and PowerShell.
|
||||
- Expanded Itô, Nasiko, agent-evaluation, multi-model council, dev-team, living-docs, secure terminal, Pi, and TasteForge workflows.
|
||||
- Improved Plan Canvas, memory vault, continuous learning, skill evolution, hook stability, session handling, and Discord delivery.
|
||||
|
||||
## Release assurance
|
||||
|
||||
- The release workflow requires the tagged commit to equal `origin/main` exactly.
|
||||
- npm registry failures stop the release instead of being treated as an unpublished version.
|
||||
- The exact packed archive is hashed once and exercised on Linux, macOS, and Windows before publication.
|
||||
- The verified npm archive is published before the matching GitHub Release is created. A retry verifies byte-for-byte registry integrity.
|
||||
|
||||
## Upgrade
|
||||
|
||||
Install or update the published package, then run the same ECC install command you used previously:
|
||||
|
||||
```bash
|
||||
npm install -g ecc-universal@2.2.0
|
||||
ecc install --target antigravity --profile full
|
||||
```
|
||||
|
||||
Use `ecc doctor --target <target>` after installation. For Antigravity, start a new conversation and verify workspace skills under Settings > Customizations.
|
||||
|
||||
## Scope audited
|
||||
|
||||
The pre-release audit covered the complete delta from `v2.1.0`: 108 commits, 530 changed files, 40,299 insertions, and 4,679 deletions before the final readiness patch.
|
||||
@@ -317,6 +317,7 @@
|
||||
"skills/security-scan/",
|
||||
"skills/seo/",
|
||||
"skills/skill-scout/",
|
||||
"skills/skill-comply/",
|
||||
"skills/skill-stocktake/",
|
||||
"skills/social-graph-ranker/",
|
||||
"skills/springboot-patterns/",
|
||||
|
||||
@@ -18,9 +18,7 @@ const PROFILES_SCHEMA_PATH = path.join(REPO_ROOT, 'schemas/install-profiles.sche
|
||||
const COMPONENTS_SCHEMA_PATH = path.join(REPO_ROOT, 'schemas/install-components.schema.json');
|
||||
const CURATED_SKILLS_DIR = path.join(REPO_ROOT, 'skills');
|
||||
// Empty by default; add only curated skills that are intentionally unshipped.
|
||||
const INTENTIONALLY_UNSHIPPED_SKILL_IDS = new Set([
|
||||
'skill-comply', // meta/measurement dev-skill; ships committed .pyc artifacts and a nested .gitignore, revisit after packaging cleanup
|
||||
]);
|
||||
const INTENTIONALLY_UNSHIPPED_SKILL_IDS = new Set([]);
|
||||
const COMPONENT_FAMILY_PREFIXES = {
|
||||
baseline: 'baseline:',
|
||||
language: 'lang:',
|
||||
|
||||
@@ -135,8 +135,8 @@ const HARNESS_CAPABILITIES = deepFreeze([
|
||||
installMode: 'managed-home',
|
||||
guidedReady: false,
|
||||
availability: 'advanced',
|
||||
destination: '~/.opencode',
|
||||
scopes: [scope('home', 'opencode', '~/.opencode')],
|
||||
destination: '~/.config/opencode',
|
||||
scopes: [scope('home', 'opencode', '~/.config/opencode')],
|
||||
hooks: hooks(
|
||||
'adapter-opt-in',
|
||||
false,
|
||||
|
||||
@@ -80,7 +80,12 @@ function validateLegacyTarget(target) {
|
||||
throw new Error(`Unknown install target: ${target}. Expected one of ${SUPPORTED_INSTALL_TARGETS.join(', ')}`);
|
||||
}
|
||||
|
||||
const IGNORED_DIRECTORY_NAMES = new Set(['node_modules', '.git', '__pycache__']);
|
||||
const IGNORED_DIRECTORY_NAMES = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'__pycache__',
|
||||
'.pytest_cache',
|
||||
]);
|
||||
const IGNORED_FILE_EXTENSIONS = new Set(['.pyc', '.pyo', '.pyd']);
|
||||
|
||||
function listFilesRecursive(dirPath) {
|
||||
|
||||
@@ -64,11 +64,55 @@ function pathsMatch(left, right) {
|
||||
return canonicalPath(left) === canonicalPath(right);
|
||||
}
|
||||
|
||||
function sameFileIdentity(left, right) {
|
||||
return left.dev === right.dev
|
||||
&& left.ino === right.ino
|
||||
&& left.size === right.size
|
||||
&& left.mtimeMs === right.mtimeMs
|
||||
&& left.ctimeMs === right.ctimeMs;
|
||||
}
|
||||
|
||||
function readRegularFileSnapshot(filePath) {
|
||||
let pathStat;
|
||||
try {
|
||||
pathStat = fs.lstatSync(filePath);
|
||||
} catch (error) {
|
||||
if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) return null;
|
||||
throw error;
|
||||
}
|
||||
if (!pathStat.isFile() || pathStat.isSymbolicLink()) {
|
||||
throw new Error(`Refusing to read a symbolic link or non-file at ${filePath}.`);
|
||||
}
|
||||
|
||||
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
|
||||
const descriptor = fs.openSync(filePath, flags);
|
||||
try {
|
||||
const before = fs.fstatSync(descriptor);
|
||||
if (!before.isFile() || !sameFileIdentity(pathStat, before)) {
|
||||
throw new Error(`Refusing to read a file that changed during open: ${filePath}.`);
|
||||
}
|
||||
const content = fs.readFileSync(descriptor);
|
||||
const after = fs.fstatSync(descriptor);
|
||||
const finalPathStat = fs.lstatSync(filePath);
|
||||
if (
|
||||
finalPathStat.isSymbolicLink()
|
||||
|| !sameFileIdentity(before, after)
|
||||
|| !sameFileIdentity(after, finalPathStat)
|
||||
) {
|
||||
throw new Error(`Refusing to read a file that changed during validation: ${filePath}.`);
|
||||
}
|
||||
return { content, stat: after };
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function fingerprintFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return { exists: false, sha256: null };
|
||||
const snapshot = readRegularFileSnapshot(filePath);
|
||||
if (!snapshot) return { exists: false, sha256: null };
|
||||
return {
|
||||
exists: true,
|
||||
sha256: crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'),
|
||||
sha256: crypto.createHash('sha256').update(snapshot.content).digest('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,11 +175,11 @@ function readOwnedDestinations(plan, dependencies) {
|
||||
} catch (error) {
|
||||
throw new Error(`Refusing to trust managed install-state path: ${error.message}`);
|
||||
}
|
||||
if (!fs.existsSync(plan.installStatePath)) {
|
||||
const initialFingerprint = fingerprintFile(plan.installStatePath);
|
||||
if (!initialFingerprint.exists) {
|
||||
return { destinations: new Set(), stateFingerprint: { exists: false, sha256: null } };
|
||||
}
|
||||
const readState = dependencies.readInstallState || require('./install-state').readInstallState;
|
||||
const initialFingerprint = fingerprintFile(plan.installStatePath);
|
||||
const state = readState(plan.installStatePath);
|
||||
const validatedFingerprint = fingerprintFile(plan.installStatePath);
|
||||
if (
|
||||
@@ -185,11 +229,12 @@ function readOwnedDestinations(plan, dependencies) {
|
||||
return { destinations, stateFingerprint: validatedFingerprint };
|
||||
}
|
||||
|
||||
function assertMergeDestination(destinationPath) {
|
||||
if (!fs.existsSync(destinationPath)) return null;
|
||||
function assertMergeDestination(destinationPath, existingSnapshot = null) {
|
||||
const snapshot = existingSnapshot || readRegularFileSnapshot(destinationPath);
|
||||
if (!snapshot) return null;
|
||||
let current;
|
||||
try {
|
||||
current = JSON.parse(fs.readFileSync(destinationPath, 'utf8'));
|
||||
current = JSON.parse(snapshot.content.toString('utf8'));
|
||||
} catch (error) {
|
||||
throw new Error(`Cannot merge ECC configuration into invalid JSON at ${destinationPath}: ${error.message}`);
|
||||
}
|
||||
@@ -218,10 +263,11 @@ function findJsonConflicts(current, patch, prefix = '') {
|
||||
|
||||
function classifyManagedOperation(operation, ownedDestinations) {
|
||||
const destinationPath = operation.destinationPath;
|
||||
if (!fs.existsSync(destinationPath)) return 'create';
|
||||
const destination = readRegularFileSnapshot(destinationPath);
|
||||
if (!destination) return 'create';
|
||||
const canonicalDestination = canonicalPath(destinationPath);
|
||||
if (operation.kind === 'merge-json') {
|
||||
const current = assertMergeDestination(destinationPath);
|
||||
const current = assertMergeDestination(destinationPath, destination);
|
||||
if (ownedDestinations.has(canonicalDestination)) return 'managed-json-update';
|
||||
const conflicts = findJsonConflicts(current, operation.mergePayload);
|
||||
if (conflicts.length > 0) {
|
||||
@@ -235,9 +281,7 @@ function classifyManagedOperation(operation, ownedDestinations) {
|
||||
if (
|
||||
operation.kind === 'copy-file'
|
||||
&& typeof operation.sourcePath === 'string'
|
||||
&& fs.existsSync(operation.sourcePath)
|
||||
&& fs.statSync(destinationPath).isFile()
|
||||
&& fs.readFileSync(operation.sourcePath).equals(fs.readFileSync(destinationPath))
|
||||
&& readRegularFileSnapshot(operation.sourcePath)?.content.equals(destination.content)
|
||||
) {
|
||||
return 'identical';
|
||||
}
|
||||
@@ -295,6 +339,9 @@ function preflightManagedPlan(plan, dependencies = {}) {
|
||||
if (!plan || !Array.isArray(plan.operations)) {
|
||||
throw new Error('A managed install plan with operations is required.');
|
||||
}
|
||||
if (typeof plan.installStatePath !== 'string' || plan.installStatePath.length === 0) {
|
||||
throw new Error('A managed install-state path is required before preflight.');
|
||||
}
|
||||
const ownership = readOwnedDestinations(plan, dependencies);
|
||||
const operations = plan.operations.map(operation => {
|
||||
assertSafeInstallOperation(plan, operation);
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
results/*.md
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
uv.lock
|
||||
@@ -252,6 +252,38 @@ function findDriftCandidate(state, cursorRoot) {
|
||||
return resolveManagedExistingPath(operation.destinationPath, cursorRoot).path;
|
||||
}
|
||||
|
||||
function runTargetSmoke(options) {
|
||||
const install = parseJsonOutput(
|
||||
options.runCli([
|
||||
'install',
|
||||
'--modules', 'workflow-quality',
|
||||
'--target', options.target,
|
||||
'--json',
|
||||
]),
|
||||
`${options.target} packed install`
|
||||
);
|
||||
assert.strictEqual(install.summary.errorCount, 0);
|
||||
const statePath = path.join(options.targetRoot, 'ecc-install-state.json');
|
||||
assert.ok(fs.existsSync(statePath), `${options.target} install-state must exist`);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(options.targetRoot, 'skills', 'skill-comply', 'SKILL.md')),
|
||||
`${options.target} must install skill-comply from the packed archive`
|
||||
);
|
||||
|
||||
const doctor = parseJsonOutput(
|
||||
options.runCli(['doctor', '--target', options.target, '--json']),
|
||||
`${options.target} packed doctor`
|
||||
);
|
||||
assert.strictEqual(doctor.summary.errorCount, 0);
|
||||
|
||||
const uninstall = parseJsonOutput(
|
||||
options.runCli(['uninstall', '--target', options.target, '--json']),
|
||||
`${options.target} packed uninstall`
|
||||
);
|
||||
assert.strictEqual(uninstall.summary.errorCount, 0);
|
||||
assert.ok(!fs.existsSync(statePath), `${options.target} uninstall must remove install-state`);
|
||||
}
|
||||
|
||||
function runLifecycle(options) {
|
||||
assert.ok(fs.existsSync(options.packagePath), `release package does not exist: ${options.packagePath}`);
|
||||
assertDownloadedArtifact(options.packagePath, process.cwd());
|
||||
@@ -450,6 +482,22 @@ function runLifecycle(options) {
|
||||
assert.strictEqual(statusAfterUninstall.installStateProjection.warningCount, 0);
|
||||
assert.strictEqual(statusAfterUninstall.readiness.status, 'ok');
|
||||
|
||||
const antigravityRoot = path.join(projectDir, '.agents');
|
||||
runTargetSmoke({
|
||||
runCli,
|
||||
target: 'antigravity',
|
||||
targetRoot: antigravityRoot,
|
||||
});
|
||||
assert.ok(!fs.existsSync(path.join(projectDir, '.agent')));
|
||||
|
||||
const opencodeRoot = path.join(homeDir, '.config', 'opencode');
|
||||
runTargetSmoke({
|
||||
runCli,
|
||||
target: 'opencode',
|
||||
targetRoot: opencodeRoot,
|
||||
});
|
||||
assert.ok(!fs.existsSync(path.join(homeDir, '.opencode')));
|
||||
|
||||
return {
|
||||
packageSha256: options.expectedSha256,
|
||||
platform: process.platform,
|
||||
@@ -469,6 +517,8 @@ function runLifecycle(options) {
|
||||
'uninstall',
|
||||
'status-uninstalled',
|
||||
'sentinel-preserved',
|
||||
'antigravity-install-doctor-uninstall',
|
||||
'opencode-install-doctor-uninstall',
|
||||
],
|
||||
};
|
||||
} finally {
|
||||
|
||||
@@ -162,12 +162,12 @@ test('packed lifecycle invokes installed public bins, including setup help', ()
|
||||
});
|
||||
|
||||
test('packed lifecycle validates canonical Antigravity and OpenCode installs', () => {
|
||||
assert.match(lifecycleRunnerSource, /'--target', 'antigravity'/);
|
||||
assert.match(lifecycleRunnerSource, /target:\s*'antigravity'/);
|
||||
assert.match(lifecycleRunnerSource, /path\.join\(projectDir, '\.agents'\)/);
|
||||
assert.match(lifecycleRunnerSource, /'--target', 'opencode'/);
|
||||
assert.match(lifecycleRunnerSource, /target:\s*'opencode'/);
|
||||
assert.match(lifecycleRunnerSource, /path\.join\(homeDir, '\.config', 'opencode'\)/);
|
||||
assert.match(lifecycleRunnerSource, /doctor.*antigravity/s);
|
||||
assert.match(lifecycleRunnerSource, /doctor.*opencode/s);
|
||||
assert.match(lifecycleRunnerSource, /\['doctor', '--target', options\.target, '--json'\]/);
|
||||
assert.match(lifecycleRunnerSource, /skill-comply.*SKILL\.md/);
|
||||
});
|
||||
|
||||
test('packed lifecycle installs and verifies the opt-in Ito distribution surface', () => {
|
||||
|
||||
@@ -90,7 +90,7 @@ function runTests() {
|
||||
cursor: ['project', './.cursor'],
|
||||
antigravity: ['project', './.agents'],
|
||||
gemini: ['project', './.gemini'],
|
||||
opencode: ['home', '~/.opencode'],
|
||||
opencode: ['home', '~/.config/opencode'],
|
||||
codebuddy: ['project', './.codebuddy'],
|
||||
joycode: ['project', './.joycode'],
|
||||
qwen: ['home', '~/.qwen'],
|
||||
|
||||
@@ -512,6 +512,15 @@ function runTests() {
|
||||
const retry = applyInstallPlan(fixture.plan);
|
||||
assert.deepStrictEqual(retry.skippedOperations, []);
|
||||
const stateAfterRetry = readInstallState(fixture.installStatePath);
|
||||
for (const originalOperation of fixture.operations) {
|
||||
assert.strictEqual(
|
||||
stateAfterRetry.operations.filter(operation => (
|
||||
operation.destinationPath === originalOperation.destinationPath
|
||||
)).length,
|
||||
1,
|
||||
`retry must record ${originalOperation.destinationPath} exactly once`
|
||||
);
|
||||
}
|
||||
const retainedExtraRecords = stateAfterRetry.operations.filter(operation => (
|
||||
operation.destinationPath === extraDestinationPath
|
||||
));
|
||||
|
||||
@@ -55,6 +55,7 @@ function writeLegacySourceFixture(root) {
|
||||
writeFile(root, path.join('rules', 'common', 'node_modules', 'ignored.md'), '# Ignored\n');
|
||||
writeFile(root, path.join('rules', 'common', '.git', 'ignored.md'), '# Ignored\n');
|
||||
writeFile(root, path.join('rules', 'common', '__pycache__', 'ignored.cpython-314.pyc'), 'ignored\n');
|
||||
writeFile(root, path.join('rules', 'common', '.pytest_cache', 'ignored.md'), '# Ignored\n');
|
||||
writeFile(root, path.join('rules', 'common', 'stray.pyc'), 'ignored\n');
|
||||
writeFile(root, path.join('rules', 'common', 'stray.pyo'), 'ignored\n');
|
||||
writeFile(root, path.join('rules', 'common', 'stray.pyd'), 'ignored\n');
|
||||
@@ -116,6 +117,7 @@ function writeManifestSourceFixture(root) {
|
||||
writeFile(root, path.join('src', 'node_modules', 'ignored.js'), 'console.log("ignored");\n');
|
||||
writeFile(root, path.join('src', '.git', 'ignored.js'), 'console.log("ignored");\n');
|
||||
writeFile(root, path.join('src', '__pycache__', 'ignored.cpython-314.pyc'), 'ignored\n');
|
||||
writeFile(root, path.join('src', '.pytest_cache', 'ignored.md'), '# Ignored\n');
|
||||
writeFile(root, path.join('src', 'stray.pyc'), 'ignored\n');
|
||||
writeFile(root, path.join('src', 'stray.pyo'), 'ignored\n');
|
||||
writeFile(root, path.join('src', 'stray.pyd'), 'ignored\n');
|
||||
@@ -201,6 +203,7 @@ function runTests() {
|
||||
assert.ok(!plan.operations.some(operation => operation.sourceRelativePath.includes('node_modules')));
|
||||
assert.ok(!plan.operations.some(operation => operation.sourceRelativePath.includes('.git')));
|
||||
assert.ok(!plan.operations.some(operation => operation.sourceRelativePath.includes('__pycache__')));
|
||||
assert.ok(!plan.operations.some(operation => operation.sourceRelativePath.includes('.pytest_cache')));
|
||||
assert.ok(!plan.operations.some(operation => /\.(?:pyc|pyo|pyd)$/.test(operation.sourceRelativePath)));
|
||||
assert.deepStrictEqual(plan.statePreview.request.legacyLanguages, ['typescript', 'missing-lang', '../bad']);
|
||||
assert.strictEqual(plan.statePreview.request.legacyMode, true);
|
||||
@@ -371,6 +374,7 @@ function runTests() {
|
||||
assert.ok(!normalizedSources.some(source => source.includes('node_modules')));
|
||||
assert.ok(!normalizedSources.some(source => source.includes('.git')));
|
||||
assert.ok(!normalizedSources.some(source => source.includes('__pycache__')));
|
||||
assert.ok(!normalizedSources.some(source => source.includes('.pytest_cache')));
|
||||
assert.ok(!normalizedSources.some(source => /\.(?:pyc|pyo|pyd)$/.test(source)));
|
||||
assert.ok(plan.operations.some(operation => (
|
||||
operation.sourceRelativePath === path.join('.claude-plugin', 'plugin.json')
|
||||
|
||||
Reference in New Issue
Block a user