Strengthen install lifecycle commands and target adapters#512
Conversation
📝 WalkthroughWalkthroughThe 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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
📝 Coding Plan
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. Comment |
Greptile SummaryThis PR significantly extends the install lifecycle by teaching repair and uninstall to handle Key changes:
Confidence Score: 4/5
Important Files Changed
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]
Last reviewed commit: fd61dfc |
| 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; | ||
| } | ||
|
|
There was a problem hiding this comment.
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.
| const previousJson = getOperationPreviousJson(operation); | ||
| if (previousJson !== undefined) { | ||
| ensureParentDir(operation.destinationPath); | ||
| fs.writeFileSync(operation.destinationPath, formatJson(previousJson)); | ||
| return { | ||
| removedPaths: [], | ||
| cleanupTargets: [], | ||
| }; | ||
| } |
There was a problem hiding this comment.
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.
| 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; |
There was a problem hiding this comment.
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);| 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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| validateOptionalString(source.repoVersion, '/source/repoVersion'); | ||
| validateOptionalString(source.repoCommit, '/source/repoCommit'); |
There was a problem hiding this comment.
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>
| 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'); | |
| } |
There was a problem hiding this comment.
💡 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".
| if (sourceRelativePath === 'rules') { | ||
| return createFlatRuleOperations({ | ||
| moduleId: module.id, | ||
| repoRoot, | ||
| sourceRelativePath, | ||
| destinationDir: path.join(targetRoot, 'rules'), |
There was a problem hiding this comment.
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 👍 / 👎.
| const currentValue = fs.existsSync(operation.destinationPath) | ||
| ? readJsonFile(operation.destinationPath) | ||
| : {}; |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (3)
scripts/lib/install-targets/registry.js (1)
31-47: Pass the full planning payload intovalidate().
planOperations()receives{ ...planningInput, modules }, butvalidate()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.As per coding guidelines, "Always validate all user input before processing at system boundaries" and "Fail fast with clear error messages when validation fails".♻️ 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);🤖 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.jsLines 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 inhelpers.jsto 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 forvalidate().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., missinghomeDirfor claude adapter, missingprojectRootfor 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
📒 Files selected for processing (11)
scripts/lib/install-lifecycle.jsscripts/lib/install-state.jsscripts/lib/install-targets/antigravity-project.jsscripts/lib/install-targets/cursor-project.jsscripts/lib/install-targets/helpers.jsscripts/lib/install-targets/registry.jstests/lib/install-lifecycle.test.jstests/lib/install-state.test.jstests/lib/install-targets.test.jstests/scripts/repair.test.jstests/scripts/uninstall.test.js
| if (state.request.legacyMode || shouldRepairFromRecordedOperations(state)) { | ||
| const operations = hydrateRecordedOperations(context.repoRoot, getManagedOperations(state)); | ||
| const statePreview = buildRecordedStatePreview(state, context, operations); |
There was a problem hiding this comment.
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);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.
| 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'); | ||
| } |
There was a problem hiding this comment.
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');
+ }📝 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.
| 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.
| 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', | ||
| })); |
There was a problem hiding this comment.
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.
* fix: strengthen install lifecycle adapters * fix: restore template content on uninstall
* fix: strengthen install lifecycle adapters * fix: restore template content on uninstall
Summary
merge-json,render-template, andremoveoperationsplanOperations()andvalidate()to install target adapters and enforce adapter validation in the registryIssues
Testing
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
merge-json,render-template, andremove; deep JSON merge/remove; write refreshed install-state after repair.planOperations()andvalidate(); registry enforces validation; Cursor/Antigravity plan flattened rules and Antigravity remaps commands→workflows and agents→skills.@ajvwhen available with a small fallback validator; deep-clones operation metadata; adds non-copy operation coverage plus repair/uninstall smoke tests.Bug Fixes
merge-jsonvia deep subset removal when no previous content).Written for commit fd61dfc. Summary will update on new commits.
Summary by CodeRabbit
Release Notes
New Features
Improvements