Skip to content

Strengthen install lifecycle commands and target adapters#512

Merged
affaan-m merged 2 commits into
mainfrom
feat/install-lifecycle
Mar 16, 2026
Merged

Strengthen install lifecycle commands and target adapters#512
affaan-m merged 2 commits into
mainfrom
feat/install-lifecycle

Conversation

@affaan-m

@affaan-m affaan-m commented Mar 16, 2026

Copy link
Copy Markdown
Owner

Summary

  • extend install lifecycle repair/uninstall logic to support recorded merge-json, render-template, and remove operations
  • add planOperations() and validate() to install target adapters and enforce adapter validation in the registry
  • fix flat multi-language rule collisions for Cursor and Antigravity by planning namespaced rule filenames
  • expand lifecycle coverage with non-copy operation tests plus installer-backed repair/uninstall smoke tests

Issues

  • ECC-29
  • ECC-30
  • ECC-9

Testing

  • node tests/scripts/install-apply.test.js
  • node tests/scripts/repair.test.js
  • node tests/scripts/uninstall.test.js
  • node tests/scripts/doctor.test.js
  • node tests/lib/install-state.test.js
  • node tests/lib/install-targets.test.js
  • node tests/lib/install-lifecycle.test.js

Summary by cubic

Strengthens repair/uninstall to execute recorded merge-json/render-template/remove operations and restore previous template/JSON content when available. Adds adapter planning/validation for safer targeting and flattens namespaced rule files for Cursor and Antigravity; addresses ECC-29, ECC-30, and ECC-9.

  • New Features

    • Repair/uninstall: execute recorded merge-json, render-template, and remove; deep JSON merge/remove; write refreshed install-state after repair.
    • Target adapters: expose planOperations() and validate(); registry enforces validation; Cursor/Antigravity plan flattened rules and Antigravity remaps commands→workflows and agents→skills.
    • Validation and tests: install-state uses @ajv when available with a small fallback validator; deep-clones operation metadata; adds non-copy operation coverage plus repair/uninstall smoke tests.
  • Bug Fixes

    • Restore rendered template files on uninstall using recorded previous content (or reverse merge-json via deep subset removal when no previous content).
    • Avoid flat multi-language rule collisions by planning namespaced, flattened rule filenames for Cursor and Antigravity.

Written for commit fd61dfc. Summary will update on new commits.

Summary by CodeRabbit

Release Notes

  • New Features

    • Enhanced installation repair pipeline with support for managing JSON modifications, template rendering, and file removal operations
    • Added validation framework for install targets with detailed error reporting
    • Improved uninstall process with operation-level execution for more granular file restoration
  • Improvements

    • Better handling of complex installation scenarios with multiple operation types
    • Fallback validation system for installations when dependencies are unavailable
    • More accurate tracking and restoration of installation state

@coderabbitai

coderabbitai Bot commented Mar 16, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

The changes refactor the install system from a legacy plan-based approach to an operation-driven pipeline. Install-lifecycle now executes operations individually via executeRepairOperation and executeUninstallOperation, with state managed through operation hydration and preview building. Install-targets modules gain planOperations methods for declarative operation planning, supported by new helper functions. Install-state adds fallback JSON schema validation when Ajv is unavailable and deep clones operation objects. Registry validates inputs and delegates operation planning to adapters.

Changes

Cohort / File(s) Summary
Core Lifecycle Pipeline
scripts/lib/install-lifecycle.js, scripts/lib/install-state.js
Refactored install-lifecycle from legacy applyInstallPlan to operation-driven executeRepairOperation and executeUninstallOperation flows. Added comprehensive JSON manipulation utilities (parseJsonLikeValue, deepMergeJson, deepRemoveJsonSubset) and operation hydration helpers (hydrateRecordedOperations, buildRecordedStatePreview). Enhanced install-state with fallback validator when Ajv unavailable, deep cloning of operation objects via cloneJsonValue, and improved error messaging.
Install Targets Planning & Helpers
scripts/lib/install-targets/antigravity-project.js, scripts/lib/install-targets/cursor-project.js, scripts/lib/install-targets/helpers.js, scripts/lib/install-targets/registry.js
Added planOperations(input, adapter) methods to install target adapters enabling module-path-based operation generation. Introduced operation builders: createManagedOperation, createFlatRuleOperations, createRemappedOperation, createNamespacedFlatRuleOperations for declarative operation construction. Added validation helpers: buildValidationIssue, defaultValidateAdapterInput. Registry now validates inputs via adapter.validate and delegates operation planning to adapter.planOperations, exposing validationIssues in results.
Test Coverage Expansion
tests/lib/install-lifecycle.test.js, tests/lib/install-state.test.js, tests/lib/install-targets.test.js, tests/scripts/repair.test.js, tests/scripts/uninstall.test.js
Extended tests to exercise new exported functions repairInstalledStates and uninstallInstalledStates with multi-operation scenarios (render-template, merge-json, remove). Added install-state validation tests for deep cloning and schema enforcement. Updated install-targets tests to verify planOperations and validate methods, reflecting new rule-flattening behavior. Expanded repair and uninstall integration tests with real install-state construction, dynamic path resolution, and multi-operation drift scenarios.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 With whiskers twitching, code takes flight,
Operations planned in structured light,
From legacy's chains to pipelines free,
Deep helpers clone what used to be,
Tests multiply, and validations shine—
Another hop along the line! 🌱✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately reflects the main objectives: it strengthens the install lifecycle (repair/uninstall of recorded operations) and enhances target adapters (adds planOperations/validate methods).

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/install-lifecycle
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps

greptile-apps Bot commented Mar 16, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR significantly extends the install lifecycle by teaching repair and uninstall to handle merge-json, render-template, and remove operations that were previously invisible to those code paths. It also introduces planOperations() and validate() on all target adapters, resolves flat rule-file name collisions for Cursor and Antigravity by namespacing filenames, and makes ajv optional with a hand-written fallback validator.

Key changes:

  • executeRepairOperation and executeUninstallOperation now handle four operation kinds; the previous code only removed files by path.
  • deepMergeJson / deepRemoveJsonSubset are well-implemented; the remove function uses a sentinel symbol to propagate "delete this key" signals up the recursion.
  • createFlatRuleOperations walks the real rules/ directory at plan time and generates one copy-path operation per file with a namespaced flat filename, fixing ECC-9.
  • install-state.js deep-clones operation metadata via JSON.parse(JSON.stringify(...)) instead of a shallow spread, preventing callers from mutating stored state.
  • Two new public exports from helpers.jscreateNamespacedFlatRuleOperations and createRemappedOperation — are not consumed by any adapter or test, and should be removed or documented as forward-looking hooks.
  • The render-template uninstall branch contains a previousJson fallback path that is unreachable with current operation schemas (template operations store previous state as text, not JSON values); this is dead code that could be confusing.
  • The flat-name algorithm in createFlatRuleOperations has a latent collision risk for files whose namespace + filename string produces the same result as another entry (e.g. rules/a/b-c.md and rules/a-b/c.md both flatten to a-b-c.md); no collision guard is in place.

Confidence Score: 4/5

  • Safe to merge with minor follow-up; core logic is correct and well-tested, with only style/quality issues remaining.
  • The core mechanics (deepMergeJson, deepRemoveJsonSubset, executeRepairOperation, executeUninstallOperation, adapter validation/planning) are logically sound and covered by thorough integration tests using real temp directories. The two issues that lower confidence from 5 are: (1) two dead exported helpers that add API noise, and (2) an unreachable code branch in the render-template uninstall path that is harmless today but could mislead future contributors. Neither issue will cause a runtime failure.
  • scripts/lib/install-targets/helpers.js (dead exports + flat-name collision risk) and scripts/lib/install-lifecycle.js (unreachable previousJson branch in render-template uninstall)

Important Files Changed

Filename Overview
scripts/lib/install-lifecycle.js Core lifecycle file gains ~420 new lines adding executeRepairOperation/executeUninstallOperation for merge-json, render-template, and remove kinds, plus deepMergeJson/deepRemoveJsonSubset helpers. Logic is sound with one unreachable previousJson branch in the render-template uninstall path and a conservative array-removal semantic worth documenting.
scripts/lib/install-targets/helpers.js Adds createFlatRuleOperations, createManagedOperation, createNamespacedFlatRuleOperations, createRemappedOperation, and validate/planOperations wiring; two of the four new public exports (createNamespacedFlatRuleOperations, createRemappedOperation) are unused dead code, and the flat-name algorithm has a latent collision risk for hyphenated filenames.
scripts/lib/install-targets/registry.js Now delegates operation planning to adapter.planOperations() and runs adapter.validate() before planning, surfacing blocking validation errors eagerly; returns validationIssues (including non-blocking warnings) in the scaffold plan. Clean, minimal change.
scripts/lib/install-targets/cursor-project.js Adds planOperations() that routes 'rules' paths through createFlatRuleOperations (namespaced flat filenames) to avoid per-language rule collisions; all other paths fall through to the default scaffold operation.
scripts/lib/install-targets/antigravity-project.js Adds planOperations() with remaps: 'commands' → workflows/, 'agents' → skills/, 'rules' → flat namespaced rule files. Correct use of createManagedScaffoldOperation for the directory remaps.
scripts/lib/install-state.js Makes Ajv optional with a comprehensive fallback validator for bare environments; upgrades operation cloning from shallow spread to deep cloneJsonValue. The fallback validator closely mirrors the Ajv schema constraints.
tests/lib/install-lifecycle.test.js Adds four new lifecycle tests covering repair and uninstall for render-template, merge-json, and remove operations. Tests use real temp directories and verify file contents, providing good end-to-end coverage of the new execute functions.
tests/lib/install-targets.test.js Updates existing scaffold test to use the real repo root (so flat rule operations resolve actual rule files) and adds three new tests: namespaced-flat cursor rules, antigravity remaps, and adapter validate/planOperations surface. Good coverage of the new planning behaviour.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[repairInstalledStates / uninstallInstalledStates] --> B[discoverInstalledStates]
    B --> C[createRepairPlanFromRecord]
    C --> D{legacyMode OR\nnon-copy-file ops?}
    D -- yes --> E[hydrateRecordedOperations\nbuildRecordedStatePreview\nmode: recorded/legacy]
    D -- no --> F[createManifestInstallPlan\nmode: manifest]
    E --> G[summarizeManagedOperationHealth\ninspectManagedOperation per op]
    F --> G
    G --> H{Repair or Uninstall?}
    H -- Repair --> I[executeRepairOperation\nper drifted/missing op]
    H -- Uninstall --> J[executeUninstallOperation\nper managed op]
    I --> I1{op.kind?}
    I1 -- copy-file --> I2[fs.copyFileSync]
    I1 -- render-template --> I3[write renderedContent]
    I1 -- merge-json --> I4[deepMergeJson + write]
    I1 -- remove --> I5[fs.rmSync]
    J --> J1{op.kind?}
    J1 -- copy-file --> J2[fs.rmSync]
    J1 -- render-template --> J3[restore previousContent\nor delete file]
    J1 -- merge-json --> J4[restore previousContent\nor deepRemoveJsonSubset]
    J1 -- remove --> J5[restore previousContent\nor no-op]
    I2 & I3 & I4 & I5 --> K[writeInstallState]
    J2 & J3 & J4 & J5 --> L[delete installStatePath]
Loading

Last reviewed commit: fd61dfc

Comment on lines +120 to +162
function createNamespacedFlatRuleOperations(adapter, moduleId, sourceRelativePath, input = {}) {
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
const sourceRoot = path.join(input.repoRoot || '', normalizedSourcePath);

if (!input.repoRoot || !fs.existsSync(sourceRoot) || !fs.statSync(sourceRoot).isDirectory()) {
return [];
}

const targetRulesDir = path.join(adapter.resolveRoot(input), 'rules');
const operations = [];
const entries = fs.readdirSync(sourceRoot, { withFileTypes: true }).sort((left, right) => (
left.name.localeCompare(right.name)
));

for (const entry of entries) {
const namespace = entry.name;
const entryPath = path.join(sourceRoot, entry.name);

if (entry.isDirectory()) {
const relativeFiles = listRelativeFiles(entryPath);
for (const relativeFile of relativeFiles) {
const flattenedFileName = `${namespace}-${normalizeRelativePath(relativeFile).replace(/\//g, '-')}`;
const sourceRelativeFile = path.join(normalizedSourcePath, namespace, relativeFile);
operations.push(createManagedOperation({
moduleId,
sourceRelativePath: sourceRelativeFile,
destinationPath: path.join(targetRulesDir, flattenedFileName),
strategy: 'flatten-copy',
}));
}
} else if (entry.isFile()) {
operations.push(createManagedOperation({
moduleId,
sourceRelativePath: path.join(normalizedSourcePath, entry.name),
destinationPath: path.join(targetRulesDir, entry.name),
strategy: 'flatten-copy',
}));
}
}

return operations;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dead code: two helpers exported but never called

createNamespacedFlatRuleOperations (line 120) and createRemappedOperation (line 107) are exported in module.exports (lines 304–305) but have zero callers anywhere in the codebase. Both Cursor and Antigravity adapters call createFlatRuleOperations directly. Shipping unused exports pollutes the public API of the module and can mislead future contributors about which helper to use when adding a new adapter.

If these are intentionally reserved for future adapters, a code comment would clarify intent. Otherwise they should be removed to keep the surface clean.

Comment on lines +390 to +398
const previousJson = getOperationPreviousJson(operation);
if (previousJson !== undefined) {
ensureParentDir(operation.destinationPath);
fs.writeFileSync(operation.destinationPath, formatJson(previousJson));
return {
removedPaths: [],
cleanupTargets: [],
};
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unreachable previousJson fallback inside render-template uninstall

The render-template branch first tries getOperationPreviousContent (looks for previousContent, originalContent, backupContent). If that fails, it falls through to getOperationPreviousJson (looks for previousValue, previousJson, originalValue) and writes the result through formatJson (pretty-printed JSON with trailing newline).

render-template operations store their previous state as raw text in previousContent; the previousValue/previousJson keys are semantically associated with merge-json operations. No test exercises this path and no code path currently writes a previousValue on a render-template operation, making these six lines effectively dead. At best it is confusing; at worst it could silently re-format a file that happened to contain valid JSON as a side-effect of some unexpected field.

Consider removing the previousJson fallback from the render-template branch, or leaving an explicit comment explaining under what future conditions it would be reached.

Comment on lines +163 to +201
function createFlatRuleOperations({ moduleId, repoRoot, sourceRelativePath, destinationDir }) {
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
const sourceRoot = path.join(repoRoot || '', normalizedSourcePath);

if (!repoRoot || !fs.existsSync(sourceRoot) || !fs.statSync(sourceRoot).isDirectory()) {
return [];
}

const operations = [];
const entries = fs.readdirSync(sourceRoot, { withFileTypes: true }).sort((left, right) => (
left.name.localeCompare(right.name)
));

for (const entry of entries) {
const namespace = entry.name;
const entryPath = path.join(sourceRoot, entry.name);

if (entry.isDirectory()) {
const relativeFiles = listRelativeFiles(entryPath);
for (const relativeFile of relativeFiles) {
const flattenedFileName = `${namespace}-${normalizeRelativePath(relativeFile).replace(/\//g, '-')}`;
operations.push(createManagedOperation({
moduleId,
sourceRelativePath: path.join(normalizedSourcePath, namespace, relativeFile),
destinationPath: path.join(destinationDir, flattenedFileName),
strategy: 'flatten-copy',
}));
}
} else if (entry.isFile()) {
operations.push(createManagedOperation({
moduleId,
sourceRelativePath: path.join(normalizedSourcePath, entry.name),
destinationPath: path.join(destinationDir, entry.name),
strategy: 'flatten-copy',
}));
}
}

return operations;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flat-name collision when a namespace contains a hyphenated filename

The flattening scheme used in createFlatRuleOperations produces the destination name ${namespace}-${relativeFile.replace(/\//g, '-')}. This means rules/typescript/pre-commit.md and rules/typescript-pre/commit.md would both resolve to typescript-pre-commit.md. With the current rule tree this is unlikely to collide, but the algorithm provides no collision detection and will silently overwrite the earlier operation without any warning if the filesystem produces them in alphabetical order.

A low-cost guard would be to track destination paths as a Set and warn (or throw) on duplicate entries before returning the operations list.

const seen = new Set();
// ... inside the loop before push:
if (seen.has(destPath)) {
  throw new Error(`Flat-rule name collision: ${destPath}`);
}
seen.add(destPath);

Comment on lines +238 to +279
function deepRemoveJsonSubset(currentValue, managedValue) {
if (isPlainObject(managedValue)) {
if (!isPlainObject(currentValue)) {
return currentValue;
}

const nextValue = { ...currentValue };
for (const [key, value] of Object.entries(managedValue)) {
if (!Object.prototype.hasOwnProperty.call(nextValue, key)) {
continue;
}

if (isPlainObject(value)) {
const nestedValue = deepRemoveJsonSubset(nextValue[key], value);
if (nestedValue === JSON_REMOVE_SENTINEL) {
delete nextValue[key];
} else {
nextValue[key] = nestedValue;
}
continue;
}

if (Array.isArray(value)) {
if (Array.isArray(nextValue[key]) && jsonContainsSubset(nextValue[key], value)) {
delete nextValue[key];
}
continue;
}

if (nextValue[key] === value) {
delete nextValue[key];
}
}

return Object.keys(nextValue).length === 0 ? JSON_REMOVE_SENTINEL : nextValue;
}

if (Array.isArray(managedValue)) {
return jsonContainsSubset(currentValue, managedValue) ? JSON_REMOVE_SENTINEL : currentValue;
}

return currentValue === managedValue ? JSON_REMOVE_SENTINEL : currentValue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deepRemoveJsonSubset uses strict array-length equality; silently skips managed arrays if they were extended

jsonContainsSubset for arrays requires actualValue.length === expectedValue.length. This means that if a user appends items to a managed array after installation (e.g. they add an extra hook to a JSON hooks file), deepRemoveJsonSubset will leave the entire array key intact during uninstall instead of removing the managed value, resulting in orphaned managed configuration.

This is a conservative choice, but it's worth documenting explicitly (a comment in the code or in the PR description) so future maintainers don't assume all managed arrays are reliably cleaned up. If the intent is to remove managed arrays only when they match exactly, a comment would also clarify why the array branch doesn't attempt partial de-merge.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 11 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="scripts/lib/install-state.js">

<violation number="1" location="scripts/lib/install-state.js:171">
P2: Fallback validation for `source.repoVersion`/`source.repoCommit` does not match the schema, causing schema-inconsistent state acceptance/rejection when Ajv is unavailable.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment on lines +171 to +172
validateOptionalString(source.repoVersion, '/source/repoVersion');
validateOptionalString(source.repoCommit, '/source/repoCommit');

@cubic-dev-ai cubic-dev-ai Bot Mar 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Fallback validation for source.repoVersion/source.repoCommit does not match the schema, causing schema-inconsistent state acceptance/rejection when Ajv is unavailable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/lib/install-state.js, line 171:

<comment>Fallback validation for `source.repoVersion`/`source.repoCommit` does not match the schema, causing schema-inconsistent state acceptance/rejection when Ajv is unavailable.</comment>

<file context>
@@ -19,12 +36,188 @@ function getValidator() {
+      pushError('/source', 'must be object');
+    } else {
+      validateNoAdditionalProperties(source, '/source', ['repoVersion', 'repoCommit', 'manifestVersion']);
+      validateOptionalString(source.repoVersion, '/source/repoVersion');
+      validateOptionalString(source.repoCommit, '/source/repoCommit');
+      if (!Number.isInteger(source.manifestVersion) || source.manifestVersion < 1) {
</file context>
Suggested change
validateOptionalString(source.repoVersion, '/source/repoVersion');
validateOptionalString(source.repoCommit, '/source/repoCommit');
if (
!Object.prototype.hasOwnProperty.call(source, 'repoVersion')
|| !(source.repoVersion === null || typeof source.repoVersion === 'string')
) {
pushError('/source/repoVersion', 'must be string or null');
}
if (
!Object.prototype.hasOwnProperty.call(source, 'repoCommit')
|| !(source.repoCommit === null || typeof source.repoCommit === 'string')
) {
pushError('/source/repoCommit', 'must be string or null');
}
Fix with Cubic

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 986756f4f6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +34 to +39
if (sourceRelativePath === 'rules') {
return createFlatRuleOperations({
moduleId: module.id,
repoRoot,
sourceRelativePath,
destinationDir: path.join(targetRoot, 'rules'),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid duplicate cursor rule destinations in manifest plans

Flattening rules into .cursor/rules/*.md creates destination collisions with the platform-configs module, which also copies .cursor/rules/*.md via the .cursor path; because those source files differ, a fresh manifest install (for example profile core, which includes both modules) is immediately marked drifted by doctor and repair can repeatedly rewrite these files. The adapter should prevent emitting overlapping destinations for these two module sources.

Useful? React with 👍 / 👎.

Comment on lines +341 to +343
const currentValue = fs.existsSync(operation.destinationPath)
? readJsonFile(operation.destinationPath)
: {};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recover from invalid JSON when repairing merge-json ops

Repairing a managed merge-json operation always parses the current destination file, so if a user has drifted that file into invalid JSON (for example by hand-editing hooks.json), repair throws a parse error and returns status: error instead of restoring managed content. This blocks the command in a common remediation scenario where repair is expected to recover from malformed managed files.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (3)
scripts/lib/install-targets/registry.js (1)

31-47: Pass the full planning payload into validate().

planOperations() receives { ...planningInput, modules }, but validate() only sees the roots. That makes module/path-specific validation impossible, so adapters cannot fail fast on the inputs that actually shape the operation list.

♻️ Keep validation and planning in sync
-  const validationIssues = adapter.validate(planningInput);
+  const adapterInput = {
+    ...planningInput,
+    modules,
+  };
+  const validationIssues = adapter.validate(adapterInput);
...
-  const operations = adapter.planOperations({
-    ...planningInput,
-    modules,
-  });
+  const operations = adapter.planOperations(adapterInput);
As per coding guidelines, "Always validate all user input before processing at system boundaries" and "Fail fast with clear error messages when validation fails".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/lib/install-targets/registry.js` around lines 31 - 47, The
adapter.validate call currently only receives planningInput (roots) while
adapter.planOperations receives { ...planningInput, modules }, which prevents
module/path-specific validation; change to build a single planningPayload that
spreads planningInput and includes modules (the same payload passed to
planOperations) and pass that planningPayload into adapter.validate (and use it
for subsequent adapter.resolveRoot/adapter.getInstallStatePath if appropriate)
so validation sees the full inputs used to plan operations.
scripts/lib/install-targets/antigravity-project.js (1)

15-29: Code duplication with cursor-project.js

Lines 15-29 (module normalization, destructuring, planningInput construction, and targetRoot resolution) are nearly identical to the same block in cursor-project.js. Consider extracting this shared logic into a helper function in helpers.js to reduce duplication across adapters.

♻️ Suggested helper extraction
// In helpers.js
function preparePlanningContext(input, adapter) {
  const modules = Array.isArray(input.modules)
    ? input.modules
    : (input.module ? [input.module] : []);
  const { repoRoot, projectRoot, homeDir } = input;
  const planningInput = { repoRoot, projectRoot, homeDir };
  const targetRoot = adapter.resolveRoot(planningInput);
  return { modules, planningInput, targetRoot, repoRoot };
}

Then in each adapter:

planOperations(input, adapter) {
  const { modules, planningInput, targetRoot, repoRoot } = preparePlanningContext(input, adapter);
  // ... target-specific path handling
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/lib/install-targets/antigravity-project.js` around lines 15 - 29,
Extract the duplicated module normalization and planning context creation from
planOperations in antigravity-project.js (which mirrors cursor-project.js) into
a shared helper (e.g., preparePlanningContext) in helpers.js; the helper should
accept input and adapter, return {modules, planningInput, targetRoot, repoRoot}
(modules computed from input.modules or input.module, planningInput built from
repoRoot/projectRoot/homeDir, and targetRoot from
adapter.resolveRoot(planningInput)); then update planOperations in both
antigravity-project.js and cursor-project.js to call preparePlanningContext and
use the returned values for their adapter-specific path handling.
tests/lib/install-targets.test.js (1)

189-206: Consider adding edge-case tests for validate().

The current tests only verify that validate() returns an empty array for valid inputs. Consider adding tests for invalid inputs to verify the validation logic catches missing required fields (e.g., missing homeDir for claude adapter, missing projectRoot for cursor adapter).

🧪 Example additional test cases
if (test('validate returns issues for missing required inputs', () => {
  const claudeAdapter = getInstallTargetAdapter('claude');
  const cursorAdapter = getInstallTargetAdapter('cursor');

  // Claude adapter should flag missing homeDir or repoRoot
  const claudeIssues = claudeAdapter.validate({});
  assert.ok(claudeIssues.length > 0, 'Should return validation issues for missing inputs');

  // Cursor adapter should flag missing projectRoot
  const cursorIssues = cursorAdapter.validate({});
  assert.ok(cursorIssues.length > 0, 'Should return validation issues for missing projectRoot');
})) passed++; else failed++;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/lib/install-targets.test.js` around lines 189 - 206, Add negative test
cases for getInstallTargetAdapter's validate function: call
getInstallTargetAdapter('claude') and getInstallTargetAdapter('cursor') and
assert that claudeAdapter.validate({}) returns a non-empty array (i.e.,
validation issues when homeDir or repoRoot are missing) and that
cursorAdapter.validate({}) returns a non-empty array (i.e., validation issues
when projectRoot is missing); use the same test harness style as existing tests
and check length > 0 (and optionally that returned issues mention the missing
field names) to ensure invalid inputs are caught.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@scripts/lib/install-lifecycle.js`:
- Around line 946-948: The code currently trusts recorded operations loaded by
hydrateRecordedOperations (used in the branch gated by state.request.legacyMode
or shouldRepairFromRecordedOperations) and can allow destinationPath or
sourceRelativePath to escape the adapter root; update hydrateRecordedOperations
or immediately after it (before buildRecordedStatePreview and before any
filesystem action elsewhere such as the repair/uninstall/copy-file flows) to
validate and reject any recorded operation whose resolved absolute path is
outside the intended root (context.repoRoot or the resolved state.target root):
resolve paths (no string-only checks), normalize/realpath them, ensure they
start with the allowed root, and either filter out or throw a clear error for
offending operations; apply the same validation where recorded operations are
later consumed (e.g., the repair/uninstall/copy handlers) so operations cannot
escape via ../ or repo-supplied state files.

In `@scripts/lib/install-state.js`:
- Around line 166-175: The fallback validation for the install state treats
source.repoVersion and source.repoCommit as optional but the schema requires
them; in the source validation block (variable "source") replace the calls to
validateOptionalString(source.repoVersion, '/source/repoVersion') and
validateOptionalString(source.repoCommit, '/source/repoCommit') with the
required-string validator used elsewhere (e.g., validateString or
validateRequiredString) so that missing or non-string repoVersion/repoCommit
will call pushError; keep the existing validateNoAdditionalProperties and the
manifestVersion integer check intact and ensure readInstallState() behavior is
deterministic across environments.

In `@scripts/lib/install-targets/helpers.js`:
- Around line 138-156: The flattened filename mapping can collide; instead of
naively replacing '/' with '-', change the flattening logic used around
listRelativeFiles/normalizeRelativePath when building flattenedFileName (and the
analogous code at the other block around createManagedOperation for files) to
encode each path segment to preserve boundaries (e.g., split
normalizeRelativePath(relativeFile) into segments and escape each segment with a
reversible encoding such as encodeURIComponent or a length-prefixed scheme, then
join with a fixed separator) and additionally keep a Set of generated
destination names to detect duplicates and throw a clear error if one occurs;
apply the same change where sourceRelativePath is constructed for single files
so createManagedOperation receives unique, unambiguous destinationPath values.

---

Nitpick comments:
In `@scripts/lib/install-targets/antigravity-project.js`:
- Around line 15-29: Extract the duplicated module normalization and planning
context creation from planOperations in antigravity-project.js (which mirrors
cursor-project.js) into a shared helper (e.g., preparePlanningContext) in
helpers.js; the helper should accept input and adapter, return {modules,
planningInput, targetRoot, repoRoot} (modules computed from input.modules or
input.module, planningInput built from repoRoot/projectRoot/homeDir, and
targetRoot from adapter.resolveRoot(planningInput)); then update planOperations
in both antigravity-project.js and cursor-project.js to call
preparePlanningContext and use the returned values for their adapter-specific
path handling.

In `@scripts/lib/install-targets/registry.js`:
- Around line 31-47: The adapter.validate call currently only receives
planningInput (roots) while adapter.planOperations receives { ...planningInput,
modules }, which prevents module/path-specific validation; change to build a
single planningPayload that spreads planningInput and includes modules (the same
payload passed to planOperations) and pass that planningPayload into
adapter.validate (and use it for subsequent
adapter.resolveRoot/adapter.getInstallStatePath if appropriate) so validation
sees the full inputs used to plan operations.

In `@tests/lib/install-targets.test.js`:
- Around line 189-206: Add negative test cases for getInstallTargetAdapter's
validate function: call getInstallTargetAdapter('claude') and
getInstallTargetAdapter('cursor') and assert that claudeAdapter.validate({})
returns a non-empty array (i.e., validation issues when homeDir or repoRoot are
missing) and that cursorAdapter.validate({}) returns a non-empty array (i.e.,
validation issues when projectRoot is missing); use the same test harness style
as existing tests and check length > 0 (and optionally that returned issues
mention the missing field names) to ensure invalid inputs are caught.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ab594fb0-6eb8-4033-962b-2cf21c3004bd

📥 Commits

Reviewing files that changed from the base of the PR and between c53bba9 and fd61dfc.

📒 Files selected for processing (11)
  • scripts/lib/install-lifecycle.js
  • scripts/lib/install-state.js
  • scripts/lib/install-targets/antigravity-project.js
  • scripts/lib/install-targets/cursor-project.js
  • scripts/lib/install-targets/helpers.js
  • scripts/lib/install-targets/registry.js
  • tests/lib/install-lifecycle.test.js
  • tests/lib/install-state.test.js
  • tests/lib/install-targets.test.js
  • tests/scripts/repair.test.js
  • tests/scripts/uninstall.test.js

Comment on lines +946 to +948
if (state.request.legacyMode || shouldRepairFromRecordedOperations(state)) {
const operations = hydrateRecordedOperations(context.repoRoot, getManagedOperations(state));
const statePreview = buildRecordedStatePreview(state, context, operations);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Reject recorded operations that escape the discovered target.

The new recorded-operation flow trusts state.target.* and every recorded destinationPath from the on-disk install-state. A repo-supplied .cursor/ecc-install-state.json can therefore point outside the current adapter root and make repair overwrite or uninstall delete arbitrary user files; copy-file repairs can also read outside context.repoRoot via .. in sourceRelativePath.

🔒 Add boundary checks before any filesystem mutation
+function assertPathInside(basePath, candidatePath, label) {
+  const relative = path.relative(path.resolve(basePath), path.resolve(candidatePath));
+  if (relative.startsWith('..') || path.isAbsolute(relative)) {
+    throw new Error(`${label} escapes ${basePath}: ${candidatePath}`);
+  }
+}
+
+function assertRecordedOperationsSafe(record, repoRoot, operations) {
+  for (const operation of operations) {
+    assertPathInside(record.targetRoot, operation.destinationPath, 'destinationPath');
+    if (repoRoot && operation.kind === 'copy-file') {
+      assertPathInside(
+        repoRoot,
+        path.join(repoRoot, operation.sourceRelativePath || ''),
+        'sourceRelativePath'
+      );
+    }
+  }
+}
...
-    const statePreview = buildRecordedStatePreview(state, context, operations);
+    assertRecordedOperationsSafe(record, context.repoRoot, operations);
+    const statePreview = buildRecordedStatePreview(state, record, context, operations);
...
-      targetRoot: state.target.root,
-      installRoot: state.target.root,
-      installStatePath: state.target.installStatePath,
+      targetRoot: record.targetRoot,
+      installRoot: record.targetRoot,
+      installStatePath: record.installStatePath,
...
-      const operations = getManagedOperations(state);
+      const operations = getManagedOperations(state);
+      assertRecordedOperationsSafe(record, null, operations);
...
-      if (fs.existsSync(state.target.installStatePath)) {
-        fs.rmSync(state.target.installStatePath, { force: true });
-        removedPaths.push(state.target.installStatePath);
-        cleanupTargets.push(state.target.installStatePath);
+      if (fs.existsSync(record.installStatePath)) {
+        fs.rmSync(record.installStatePath, { force: true });
+        removedPaths.push(record.installStatePath);
+        cleanupTargets.push(record.installStatePath);
As per coding guidelines, "Never trust external data (API responses, user input, file content)" and "Always validate all user input before processing at system boundaries".

Also applies to: 1049-1052, 1161-1167

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/lib/install-lifecycle.js` around lines 946 - 948, The code currently
trusts recorded operations loaded by hydrateRecordedOperations (used in the
branch gated by state.request.legacyMode or shouldRepairFromRecordedOperations)
and can allow destinationPath or sourceRelativePath to escape the adapter root;
update hydrateRecordedOperations or immediately after it (before
buildRecordedStatePreview and before any filesystem action elsewhere such as the
repair/uninstall/copy-file flows) to validate and reject any recorded operation
whose resolved absolute path is outside the intended root (context.repoRoot or
the resolved state.target root): resolve paths (no string-only checks),
normalize/realpath them, ensure they start with the allowed root, and either
filter out or throw a clear error for offending operations; apply the same
validation where recorded operations are later consumed (e.g., the
repair/uninstall/copy handlers) so operations cannot escape via ../ or
repo-supplied state files.

Comment on lines +166 to +175
const source = state.source;
if (!source || typeof source !== 'object' || Array.isArray(source)) {
pushError('/source', 'must be object');
} else {
validateNoAdditionalProperties(source, '/source', ['repoVersion', 'repoCommit', 'manifestVersion']);
validateOptionalString(source.repoVersion, '/source/repoVersion');
validateOptionalString(source.repoCommit, '/source/repoCommit');
if (!Number.isInteger(source.manifestVersion) || source.manifestVersion < 1) {
pushError('/source/manifestVersion', 'must be integer >= 1');
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Make the fallback source validation match the schema.

The inline validator still treats source.repoVersion and source.repoCommit as optional here, so the same install-state can be accepted in bare environments and rejected once the schema-backed path is available. That makes readInstallState() environment-dependent for malformed state files.

🛠️ Fix the required-field check
-      validateOptionalString(source.repoVersion, '/source/repoVersion');
-      validateOptionalString(source.repoCommit, '/source/repoCommit');
+      if (
+        !Object.prototype.hasOwnProperty.call(source, 'repoVersion')
+        || (source.repoVersion !== null && typeof source.repoVersion !== 'string')
+      ) {
+        pushError('/source/repoVersion', 'must be string or null');
+      }
+      if (
+        !Object.prototype.hasOwnProperty.call(source, 'repoCommit')
+        || (source.repoCommit !== null && typeof source.repoCommit !== 'string')
+      ) {
+        pushError('/source/repoCommit', 'must be string or null');
+      }
As per coding guidelines, "Use schema-based validation where available".
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const source = state.source;
if (!source || typeof source !== 'object' || Array.isArray(source)) {
pushError('/source', 'must be object');
} else {
validateNoAdditionalProperties(source, '/source', ['repoVersion', 'repoCommit', 'manifestVersion']);
validateOptionalString(source.repoVersion, '/source/repoVersion');
validateOptionalString(source.repoCommit, '/source/repoCommit');
if (!Number.isInteger(source.manifestVersion) || source.manifestVersion < 1) {
pushError('/source/manifestVersion', 'must be integer >= 1');
}
const source = state.source;
if (!source || typeof source !== 'object' || Array.isArray(source)) {
pushError('/source', 'must be object');
} else {
validateNoAdditionalProperties(source, '/source', ['repoVersion', 'repoCommit', 'manifestVersion']);
if (
!Object.prototype.hasOwnProperty.call(source, 'repoVersion')
|| (source.repoVersion !== null && typeof source.repoVersion !== 'string')
) {
pushError('/source/repoVersion', 'must be string or null');
}
if (
!Object.prototype.hasOwnProperty.call(source, 'repoCommit')
|| (source.repoCommit !== null && typeof source.repoCommit !== 'string')
) {
pushError('/source/repoCommit', 'must be string or null');
}
if (!Number.isInteger(source.manifestVersion) || source.manifestVersion < 1) {
pushError('/source/manifestVersion', 'must be integer >= 1');
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/lib/install-state.js` around lines 166 - 175, The fallback validation
for the install state treats source.repoVersion and source.repoCommit as
optional but the schema requires them; in the source validation block (variable
"source") replace the calls to validateOptionalString(source.repoVersion,
'/source/repoVersion') and validateOptionalString(source.repoCommit,
'/source/repoCommit') with the required-string validator used elsewhere (e.g.,
validateString or validateRequiredString) so that missing or non-string
repoVersion/repoCommit will call pushError; keep the existing
validateNoAdditionalProperties and the manifestVersion integer check intact and
ensure readInstallState() behavior is deterministic across environments.

Comment on lines +138 to +156
if (entry.isDirectory()) {
const relativeFiles = listRelativeFiles(entryPath);
for (const relativeFile of relativeFiles) {
const flattenedFileName = `${namespace}-${normalizeRelativePath(relativeFile).replace(/\//g, '-')}`;
const sourceRelativeFile = path.join(normalizedSourcePath, namespace, relativeFile);
operations.push(createManagedOperation({
moduleId,
sourceRelativePath: sourceRelativeFile,
destinationPath: path.join(targetRulesDir, flattenedFileName),
strategy: 'flatten-copy',
}));
}
} else if (entry.isFile()) {
operations.push(createManagedOperation({
moduleId,
sourceRelativePath: path.join(normalizedSourcePath, entry.name),
destinationPath: path.join(targetRulesDir, entry.name),
strategy: 'flatten-copy',
}));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

The flattened rule filename mapping is still ambiguous.

Replacing / with - is not injective: a/b-c.md, a-b/c.md, and a top-level a-b-c.md all collapse to the same destination name. One generated operation will silently win and another rule file will disappear.

💡 Use an escaped segment encoding (or detect duplicates and fail)
+function flattenRulePath(...segments) {
+  return segments
+    .flatMap(segment => normalizeRelativePath(segment).split('/'))
+    .map(segment => segment.replace(/-/g, '--'))
+    .join('-');
+}
...
-        const flattenedFileName = `${namespace}-${normalizeRelativePath(relativeFile).replace(/\//g, '-')}`;
+        const flattenedFileName = flattenRulePath(namespace, relativeFile);
...
-        destinationPath: path.join(targetRulesDir, entry.name),
+        destinationPath: path.join(targetRulesDir, flattenRulePath(entry.name)),
...
-        const flattenedFileName = `${namespace}-${normalizeRelativePath(relativeFile).replace(/\//g, '-')}`;
+        const flattenedFileName = flattenRulePath(namespace, relativeFile);
...
-        destinationPath: path.join(destinationDir, entry.name),
+        destinationPath: path.join(destinationDir, flattenRulePath(entry.name)),

Also applies to: 180-197

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/lib/install-targets/helpers.js` around lines 138 - 156, The flattened
filename mapping can collide; instead of naively replacing '/' with '-', change
the flattening logic used around listRelativeFiles/normalizeRelativePath when
building flattenedFileName (and the analogous code at the other block around
createManagedOperation for files) to encode each path segment to preserve
boundaries (e.g., split normalizeRelativePath(relativeFile) into segments and
escape each segment with a reversible encoding such as encodeURIComponent or a
length-prefixed scheme, then join with a fixed separator) and additionally keep
a Set of generated destination names to detect duplicates and throw a clear
error if one occurs; apply the same change where sourceRelativePath is
constructed for single files so createManagedOperation receives unique,
unambiguous destinationPath values.

@affaan-m affaan-m merged commit 131f977 into main Mar 16, 2026
30 of 40 checks passed
@affaan-m affaan-m deleted the feat/install-lifecycle branch March 20, 2026 07:15
floatingman pushed a commit to floatingman/everything-claude-code that referenced this pull request Mar 22, 2026
* fix: strengthen install lifecycle adapters

* fix: restore template content on uninstall
FrancescoRosciano pushed a commit to FRosciano-Mambo/everything-claude-code that referenced this pull request Jun 1, 2026
* fix: strengthen install lifecycle adapters

* fix: restore template content on uninstall
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant