Skip to content

refac: registry error flow via Effect#6

Open
cau1k wants to merge 3 commits intomainfrom
refac/effect-registry-hardening
Open

refac: registry error flow via Effect#6
cau1k wants to merge 3 commits intomainfrom
refac/effect-registry-hardening

Conversation

@cau1k
Copy link
Copy Markdown
Member

@cau1k cau1k commented Dec 13, 2025

Summary

  • Incrementally adopt effect internally in the registry module to replace throws/try-catch with typed, explicit failure paths.
  • Registry APIs now return Result for fetch/resolve/install flows; CLI updated to handle and format structured errors.

Notes

  • Adds effect dependency to @better-slop/core via workspace catalog.

Summary by CodeRabbit

  • New Features

    • Added safer, effect-driven install and registry flows for more predictable operations.
  • Bug Fixes

    • Centralized and clearer CLI error messages and exit behavior.
    • More resilient registry fetching and parsing with structured error reporting.
  • Improvements

    • Safer file and config writes (atomic/staged) and improved post-install handling and reporting.
    • Improved JSON-with-comments handling and overall reliability of resolution/install processes.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Dec 13, 2025

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

This PR adds the Effect library and rewrites registry control flow and error handling to use Result/Effect patterns, replacing many Promise/throw-based paths with typed error unions, Result utilities, and effectful I/O across registry resolution, parsing, planning, and installation.

Changes

Cohort / File(s) Summary
Dependency additions
package.json, packages/core/package.json
Added "effect" dependency (catalog:) to enable Effect-based control flow; minor formatting adjustment in root package.json.
Error taxonomy
packages/core/src/registry/errors.ts (new)
New discriminated union error types (JSONCError, RegistryItemParseError, FetchRegistryItemError, ResolveRegistryTreeError, InstallError) for structured error handling.
Result utilities
packages/core/src/registry/result.ts (new)
New generic Result<T,E> implementation with Ok/Err, constructors, predicates (isOk/isErr), combinators, promise interop, and Effect interop helpers.
Embedded retrieval (effectified)
packages/core/src/registry/embedded.ts
Replaced Promise-based getEmbeddedRegistryItem with getEmbeddedRegistryItemEffect returning Effect; added EmbeddedReadFailed type and switched to Effect.tryPromise pipeline.
Registry fetch (effectified)
packages/core/src/registry/registry.ts
Added fetchRegistryItemEffect and wrapper fetchRegistryItem returning Result; moved HTTP/FS/JSON parsing into Effect pipelines and mapped errors to FetchRegistryItemError variants.
Resolve tree (effectified)
packages/core/src/registry/resolve.ts
Introduced resolveRegistryTreeEffect and dependencyCycle handling; replaced recursive Promise traversal with Effect-based visiting, cycle detection, and concurrency control; exported wrapper returns Result.
JSONC result paths
packages/core/src/registry/jsonc.ts
Converted JSONC helpers to JSONCResult-based returns; getTopLevelJsoncPropertyValueText, upsertTopLevelJsoncProperty, and formatValueForProperty now return JSONCResult types.
Install orchestration (effectified)
packages/core/src/registry/install.ts
Reworked planInstalls and applyInstallPlans to Result/Effect model; added many effectful FS helpers (writeAtomicEffect, stageDirEffect, runPostinstallEffect), error constructors, and safer atomic/staged write flows; signatures updated to return Result/Promise<Result<...>>.
Public exports updated
packages/core/src/registry/index.ts
Exported new modules/types: listEmbeddedItems, result types (Err/Ok/Result), isErr/isOk, Fetch/Install/Resolve error types, ResolvedItem, and extended type exports from types module.
CLI integration
packages/cli/index.ts
Added stringifyCause, formatError, exitWithError; refactored runAdd and postinstall printing to handle Result/Effect-shaped outputs and explicit error variants; replaced parseAsync try/catch with .catch handling.
Small refactor
packages/core/src/registry/config-root.ts
isDir refactored from async/await to promise chain (.then/.catch) while preserving behavior.

Sequence Diagram(s)

mermaid
sequenceDiagram
autonumber
participant CLI
participant Resolve as "resolveRegistryTreeEffect"
participant Plan as "planInstalls"
participant Apply as "applyInstallPlans"
participant FS
participant Net as "fetchRegistryItemEffect"
CLI->>Resolve: request resolve(specs)
Resolve->>Net: fetch items (embedded/URL/file)
Net-->>Resolve: Result<item, FetchError>
Resolve-->>CLI: Result<ResolvedItem[], ResolveError>
CLI->>Plan: planInstalls(resolved, configRoot)
Plan-->>CLI: Result<InstallPlan[], InstallError>
CLI->>Apply: applyInstallPlans(plans)
Apply->>FS: staged writes / atomic writes
Apply->>Net: postinstall network/exec (if any)
FS-->>Apply: write results / IO errors
Apply-->>CLI: Result<ApplyInstallResult, InstallError>

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

-重点审查:

  • packages/core/src/registry/install.ts — atomic/staged writes, effect composition, postinstall error aggregation.
  • packages/core/src/registry/registry.ts — effect error mapping for fetch/read/parse paths.
  • packages/core/src/registry/resolve.ts — visiting/state management and cycle detection in effect pipeline.
  • packages/cli/index.ts — mapping Resolve/Install error variants to user messages and exit behavior.
  • packages/core/src/registry/jsonc.ts — ensure JSONCResult propagation matches previous semantics.

Poem

🐰
I hopped through Effects, with carrots of types,
Wrapping each error in tidy delights,
Plans laid in burrows, writes safe and sound,
No more wild throws — just Results all around!

Pre-merge checks and finishing touches

❌ 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%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and accurately summarizes the main objective of the PR: refactoring the registry error flow to use the Effect library instead of throws/try-catch.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch refac/effect-registry-hardening

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.

@cau1k cau1k requested a review from Copilot December 13, 2025 05:14
@github-actions
Copy link
Copy Markdown

github-actions bot commented Dec 13, 2025

Preview Deployment

🔗 https://pr-6.better-slop.com

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (6)
packages/core/src/registry/config-root.ts (1)

6-8: isDir refactor preserves semantics; consider dropping redundant async.
Since you’re returning the promise chain directly, async isn’t adding value here (but keeping it is harmless).

-async function isDir(p: string): Promise<boolean> {
+function isDir(p: string): Promise<boolean> {
   return stat(p).then((s) => s.isDirectory()).catch(() => false);
 }
package.json (1)

15-15: Consider pinning the effect version for reproducibility.

Using "latest" can lead to unexpected breaking changes when the dependency is updated. Since effect is a core library for error handling across the registry module, consider pinning to a specific version (e.g., "3.x.x") to ensure consistent behavior across environments and CI runs.

packages/core/src/registry/embedded.ts (1)

14-18: Consider adding as const to the error type for stricter discrimination.

For better type narrowing with discriminated unions, you could use a const assertion or readonly modifier on _tag:

 export type EmbeddedReadFailed = {
-  _tag: "EmbeddedReadFailed";
+  readonly _tag: "EmbeddedReadFailed";
   name: string;
   cause: unknown;
 };

This prevents accidental mutation and improves TypeScript's ability to narrow the type in switch/if statements.

packages/cli/index.ts (1)

89-100: Consider extracting the inline type for printHooks.

The inline type Array<{ item: { kind: string; name: string }; postinstall: null | { commands: string[] } }> is functional but verbose. If InstallPlan is exported from core, you could use Pick<InstallPlan, 'item' | 'postinstall'>[] for clarity and to stay in sync with the core types.

packages/core/src/registry/install.ts (2)

106-109: Consider explicit handling for unexpected mode values.

The toMode function defaults to 0o755 for any value other than "0644". If an invalid mode string is passed, this silent fallback could lead to unexpected permissions.

 function toMode(mode: "0644" | "0755"): number {
   if (mode === "0644") return 0o644;
+  if (mode === "0755") return 0o755;
+  // TypeScript ensures exhaustiveness, but this guards against runtime issues
   return 0o755;
 }

315-381: Clean Result-based validation flow.

The function properly validates each entry and file path, returning early on validation errors. The Result type makes error propagation explicit and type-safe.

I noticed the TODO on line 375 about bun add defaults. Would you like me to open an issue to track implementing dependency installation support?

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between fa4cc79 and 354c238.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (12)
  • package.json (1 hunks)
  • packages/cli/index.ts (5 hunks)
  • packages/core/package.json (1 hunks)
  • packages/core/src/registry/config-root.ts (1 hunks)
  • packages/core/src/registry/embedded.ts (3 hunks)
  • packages/core/src/registry/errors.ts (1 hunks)
  • packages/core/src/registry/index.ts (1 hunks)
  • packages/core/src/registry/install.ts (5 hunks)
  • packages/core/src/registry/jsonc.ts (9 hunks)
  • packages/core/src/registry/registry.ts (2 hunks)
  • packages/core/src/registry/resolve.ts (1 hunks)
  • packages/core/src/registry/result.ts (1 hunks)
🧰 Additional context used
📓 Path-based instructions (5)
package.json

📄 CodeRabbit inference engine (CLAUDE.md)

package.json: Use bun install instead of npm install, yarn install, or pnpm install for dependency management
Use bun run <script> instead of npm run <script>, yarn run <script>, or pnpm run <script> for running scripts defined in package.json

Files:

  • package.json
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx,js,jsx}: Use bun <file> instead of node <file> or ts-node <file> for running scripts
Bun automatically loads .env files, so don't use the dotenv package
Use bun:sqlite for SQLite database operations instead of better-sqlite3
Use Bun.redis for Redis operations instead of ioredis
Use Bun.sql for Postgres database operations instead of pg or postgres.js
Use built-in WebSocket instead of the ws package
Prefer Bun.file over node:fs readFile/writeFile methods for file operations
Use Bun.$ for shell commands instead of execa

Files:

  • packages/core/src/registry/resolve.ts
  • packages/core/src/registry/config-root.ts
  • packages/core/src/registry/errors.ts
  • packages/core/src/registry/jsonc.ts
  • packages/core/src/registry/result.ts
  • packages/cli/index.ts
  • packages/core/src/registry/registry.ts
  • packages/core/src/registry/install.ts
  • packages/core/src/registry/embedded.ts
  • packages/core/src/registry/index.ts
**/*.{html,ts,tsx,css}

📄 CodeRabbit inference engine (CLAUDE.md)

Use bun build <file.html|file.ts|file.css> instead of webpack or esbuild for bundling

Files:

  • packages/core/src/registry/resolve.ts
  • packages/core/src/registry/config-root.ts
  • packages/core/src/registry/errors.ts
  • packages/core/src/registry/jsonc.ts
  • packages/core/src/registry/result.ts
  • packages/cli/index.ts
  • packages/core/src/registry/registry.ts
  • packages/core/src/registry/install.ts
  • packages/core/src/registry/embedded.ts
  • packages/core/src/registry/index.ts
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx}: Use Bun.serve() with built-in WebSocket, HTTPS, and routing support instead of express
Use bun --hot to run TypeScript files with hot module reloading for development

Files:

  • packages/core/src/registry/resolve.ts
  • packages/core/src/registry/config-root.ts
  • packages/core/src/registry/errors.ts
  • packages/core/src/registry/jsonc.ts
  • packages/core/src/registry/result.ts
  • packages/cli/index.ts
  • packages/core/src/registry/registry.ts
  • packages/core/src/registry/install.ts
  • packages/core/src/registry/embedded.ts
  • packages/core/src/registry/index.ts
**/*.{html,tsx,ts}

📄 CodeRabbit inference engine (CLAUDE.md)

Use HTML imports with Bun.serve() for frontend development instead of vite

Files:

  • packages/core/src/registry/resolve.ts
  • packages/core/src/registry/config-root.ts
  • packages/core/src/registry/errors.ts
  • packages/core/src/registry/jsonc.ts
  • packages/core/src/registry/result.ts
  • packages/cli/index.ts
  • packages/core/src/registry/registry.ts
  • packages/core/src/registry/install.ts
  • packages/core/src/registry/embedded.ts
  • packages/core/src/registry/index.ts
🧠 Learnings (7)
📚 Learning: 2025-12-04T21:23:25.944Z
Learnt from: CR
Repo: better-slop/ocx PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-04T21:23:25.944Z
Learning: Applies to **/*.{ts,tsx} : Use `bun --hot` to run TypeScript files with hot module reloading for development

Applied to files:

  • package.json
📚 Learning: 2025-12-04T21:23:25.944Z
Learnt from: CR
Repo: better-slop/ocx PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-04T21:23:25.944Z
Learning: Applies to **/*.{ts,tsx,js,jsx} : Use `bun <file>` instead of `node <file>` or `ts-node <file>` for running scripts

Applied to files:

  • package.json
📚 Learning: 2025-12-04T21:23:25.944Z
Learnt from: CR
Repo: better-slop/ocx PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-04T21:23:25.944Z
Learning: Applies to **/*.{html,ts,tsx,css} : Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild` for bundling

Applied to files:

  • package.json
📚 Learning: 2025-12-04T21:23:25.944Z
Learnt from: CR
Repo: better-slop/ocx PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-04T21:23:25.944Z
Learning: Applies to package.json : Use `bun run <script>` instead of `npm run <script>`, `yarn run <script>`, or `pnpm run <script>` for running scripts defined in package.json

Applied to files:

  • package.json
📚 Learning: 2025-12-04T21:23:25.944Z
Learnt from: CR
Repo: better-slop/ocx PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-04T21:23:25.944Z
Learning: Applies to **/*.{ts,tsx,js,jsx} : Use `Bun.$` for shell commands instead of `execa`

Applied to files:

  • package.json
📚 Learning: 2025-12-04T21:23:25.944Z
Learnt from: CR
Repo: better-slop/ocx PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-04T21:23:25.944Z
Learning: Applies to **/*.{ts,tsx,js,jsx} : Bun automatically loads .env files, so don't use the dotenv package

Applied to files:

  • package.json
📚 Learning: 2025-12-04T21:23:25.944Z
Learnt from: CR
Repo: better-slop/ocx PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-04T21:23:25.944Z
Learning: Applies to package.json : Use `bun install` instead of `npm install`, `yarn install`, or `pnpm install` for dependency management

Applied to files:

  • package.json
🧬 Code graph analysis (3)
packages/core/src/registry/jsonc.ts (1)
packages/core/src/registry/result.ts (3)
  • Result (13-13)
  • err (19-21)
  • ok (15-17)
packages/cli/index.ts (4)
packages/core/src/registry/index.ts (6)
  • ResolveRegistryTreeError (13-13)
  • InstallError (10-10)
  • resolveRegistryTree (5-5)
  • isErr (6-6)
  • planInstalls (1-1)
  • applyInstallPlans (1-1)
packages/core/src/registry/resolve.ts (1)
  • resolveRegistryTree (59-64)
packages/core/src/registry/result.ts (2)
  • isErr (27-29)
  • err (19-21)
packages/core/src/registry/install.ts (2)
  • planInstalls (315-381)
  • applyInstallPlans (426-434)
packages/core/src/registry/registry.ts (4)
packages/core/src/registry/index.ts (5)
  • RegistryItemParseError (12-12)
  • FetchRegistryItemError (9-9)
  • Result (15-15)
  • RegistryItemV1 (23-23)
  • RegistryItem (22-22)
packages/core/src/registry/result.ts (5)
  • Result (13-13)
  • ok (15-17)
  • err (19-21)
  • toEffect (59-62)
  • runEffect (64-73)
packages/core/src/registry/types.ts (2)
  • RegistryItemV1 (14-30)
  • RegistryItem (32-32)
packages/core/src/registry/embedded.ts (1)
  • getEmbeddedRegistryItemEffect (20-49)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Agent
  • GitHub Check: CodeQL analysis (javascript-typescript)
🔇 Additional comments (29)
packages/core/package.json (1)

16-17: LGTM!

Correctly referencing the effect dependency from the workspace catalog ensures consistent versioning across packages.

packages/core/src/registry/index.ts (1)

1-24: LGTM!

The barrel file cleanly organizes the public API surface with proper separation of runtime exports and type-only exports. The new Result utilities (isErr, isOk) and error types are appropriately exposed for consumers.

packages/core/src/registry/embedded.ts (1)

31-48: LGTM!

Good use of Effect.tryPromise to wrap the async file read operation with proper error mapping. The functional pipeline cleanly transforms the file content into the registry item structure.

packages/core/src/registry/resolve.ts (2)

16-57: LGTM!

The Effect-based DFS implementation correctly handles:

  • Cycle detection: Using a visiting set with proper cleanup via Effect.ensuring
  • Deduplication: Using a map to skip already-resolved items
  • Sequential traversal: concurrency: 1 ensures proper DFS ordering
  • Post-order output: Items are pushed to out after their dependencies resolve

The closure over mutable state (map, visiting, out) is safe here due to sequential execution.


12-14: The suggestion to add as const is unnecessary. The dependencyCycle function is already consistent with all other error types in the codebase. None of the error type definitions in packages/core/src/registry/errors.ts use readonly on _tag—this includes JSONCError, RegistryItemParseError, FetchRegistryItemError, ResolveRegistryTreeError, and InstallError. The current implementation matches the established pattern across the entire error type system.

packages/core/src/registry/result.ts (1)

1-73: Well-structured Result type implementation.

The discriminated union approach with _tag is idiomatic and the combinators (map, flatMap, mapError) correctly propagate errors.

One consideration: runEffect uses Effect.runPromise which will reject on defects (unexpected errors). If a defect occurs inside the Effect, the outer Promise will reject rather than returning an Err. This is likely intentional since defects represent programmer errors, but worth documenting for consumers.

packages/core/src/registry/jsonc.ts (4)

14-22: Good internal Result helpers.

The fail<T> helper cleanly wraps error creation. The generic parameter allows proper type inference at call sites.


212-228: Consistent Result propagation in findValueEnd.

The early-return pattern on ._tag === "Err" correctly short-circuits and preserves the error type.


421-448: Public API correctly returns Result.

The refactored getTopLevelJsoncPropertyValueText properly chains Result checks and returns ok(null) for missing properties vs ok(value) for found ones.


450-496: upsertTopLevelJsoncProperty handles all Result paths.

Error propagation is complete through rootCloseRes, spanRes, and formattedValueRes. Both update and insert paths correctly return ok(...).

packages/core/src/registry/registry.ts (5)

8-34: Clean error factory functions.

These constructors align with the error types in errors.ts and provide a single place to create each variant.


62-154: Thorough parseV1 validation with Result-based error handling.

All required fields are validated, and the structured error messages will help users diagnose malformed registry items. The files loop correctly accumulates valid entries while short-circuiting on the first error.


164-194: Effect helpers correctly wrap I/O and parsing.

  • fetchJSONEffect properly distinguishes network failures from HTTP status errors
  • readTextEffect uses Bun.file as per coding guidelines
  • parseJsonEffect uses Effect.try for synchronous parsing

201-241: Well-structured Effect pipeline for registry item fetching.

The fallback chain (embedded → URL → file → unknown) is clear. The mapError at Line 212 correctly widens EmbeddedReadFailed to the full FetchRegistryItemError union for uniform error handling downstream.


243-248: Clean Promise-based public API wrapper.

fetchRegistryItem bridges the Effect-based implementation to a Promise-returning API via runEffect, maintaining backward compatibility for consumers that prefer Promises.

packages/core/src/registry/errors.ts (1)

1-32: Comprehensive error taxonomy with discriminated unions.

The _tag discriminant enables exhaustive pattern matching in consumers like the CLI's formatError. The compositional approach (e.g., ResolveRegistryTreeError including FetchRegistryItemError) avoids duplication while preserving type precision.

Minor observation: The cause: unknown fields on several variants allow capturing the underlying error, which is good for debugging. Consider whether you want to expose a utility to safely extract stack traces from these causes for logging.

packages/cli/index.ts (2)

30-82: Exhaustive error formatting via switch on _tag.

The switch covers all variants of ResolveRegistryTreeError and InstallError. TypeScript will flag missing cases if new error variants are added to the unions, ensuring this stays in sync.


218-222: Catch block ensures CLI exits gracefully on unexpected errors.

The .catch on parseAsync handles any unhandled rejections and sets the exit code appropriately.

packages/core/src/registry/install.ts (11)

1-9: Imports look good.

The imports are well-organized, bringing in the necessary Node.js modules for filesystem operations, the Effect library for the new error-handling pattern, and local modules for types and utilities.


11-23: Type definitions are well-structured.

The InstalledOCXItem and OCXManagedConfig types clearly define the shape of installed items and the managed configuration.


25-55: Error constructors follow a clean discriminated union pattern.

The tagged error objects with _tag discriminators enable exhaustive pattern matching and provide good context for each error type.


57-80: Utility functions are correctly implemented.

The helper functions handle edge cases properly—isRecord excludes arrays and null, and stringifyCause gracefully handles both Error instances and other thrown values.


82-98: Solid path validation with security considerations.

The function correctly prevents path traversal attacks by checking for absolute paths, parent directory escapes, and empty paths. Using path.posix ensures consistent behavior across platforms.


111-155: Effect wrappers are well-structured with proper error mapping.

The filesystem operation wrappers consistently convert native errors to typed InstallError values. Using Bun.write aligns with the coding guidelines for file operations.


157-220: Well-designed atomic write and staging patterns.

The withTmpDirEffect correctly implements resource acquisition/release semantics. The stageDirEffect function provides important security validation by checking that writes don't escape the target directory. The atomic rename pattern ensures crash-safe installations.


222-253: Robust JSONC parsing with graceful fallbacks.

The function handles missing or malformed ocx properties gracefully, using ensureRecordMap to safely coerce each item kind. This defensive approach prevents crashes from corrupted config files.


283-313: Postinstall execution with appropriate opt-in control.

The function correctly uses sequential execution (concurrency: 1) for postinstall commands and requires explicit opt-in via allowPostinstall. Using bash -lc ensures the user's shell profile is loaded, which is typical for tools that need proper PATH resolution.


383-424: Effectful installation pipeline is well-structured.

The function properly sequences the installation steps: creating directories, staging files atomically, updating configuration, and optionally running postinstall scripts. The assumption that all plans share the same configRoot (line 390) is reasonable for batch installation operations.


426-438: Clean async wrapper maintains backward compatibility.

The applyInstallPlans wrapper provides a familiar Promise-based API for consumers not using Effect directly, while runEffect handles the Effect-to-Result conversion internally.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR incrementally adopts the effect library to refactor the registry module's error handling, replacing traditional throws/try-catch patterns with typed, explicit failure paths using Result types. This change improves type safety and makes error paths explicit throughout the registry APIs.

Key changes:

  • Introduced Result type and Effect-based error handling infrastructure
  • Updated registry APIs (fetch, resolve, install) to return Result<T, E> types
  • Modified CLI to handle and format structured errors from the registry

Reviewed changes

Copilot reviewed 12 out of 13 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/core/src/registry/result.ts New Result type implementation with utilities for Effect integration
packages/core/src/registry/errors.ts Defines typed error unions for registry operations
packages/core/src/registry/registry.ts Refactored fetch and parse logic using Effect and Result
packages/core/src/registry/resolve.ts Converted dependency resolution to Effect-based flow
packages/core/src/registry/install.ts Migrated install operations to Effect with structured errors
packages/core/src/registry/jsonc.ts Updated JSONC parsing to return Result types
packages/core/src/registry/embedded.ts Converted embedded item fetching to Effect
packages/core/src/registry/config-root.ts Minor refactoring of error handling
packages/core/src/registry/index.ts Updated exports to include Result types and error utilities
packages/cli/index.ts Added error formatting and handling for structured Result types
packages/core/package.json Added effect dependency from workspace catalog
package.json Added effect to workspace catalog
bun.lock Locked effect@3.19.11 and dependencies

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@cau1k
Copy link
Copy Markdown
Member Author

cau1k commented Dec 13, 2025

Addressed review comments:

  • packages/core/src/registry/install.ts: config read now distinguishes missing file vs other read errors (no more blanket "{}" fallback); renamed createJSONCError; made toMode explicit.
  • packages/core/src/registry/jsonc.ts: renamed helper to createJSONCError.
  • packages/core/src/registry/config-root.ts: dropped redundant async on isDir.
  • packages/core/src/registry/embedded.ts: made _tag readonly.
  • packages/cli/index.ts: printHooks now uses Pick<InstallPlan, 'item' | 'postinstall'> and renamed withHooks.
  • package.json: pinned effect catalog version to 3.19.11.

I didn’t open a separate issue for the bun add defaults TODO yet—say the word and I’ll file it.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

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)
packages/core/src/registry/config-root.ts (1)

6-7: Consider surfacing errors instead of silently catching them.

The change from async to promise chaining is correct and maintains the same behavior. However, the .catch(() => false) silently swallows all errors (permission denied, I/O errors, etc.), which seems inconsistent with this PR's goal of adopting Effect for explicit, typed failure paths.

While returning false may be acceptable for a simple "does directory exist?" check, it prevents distinguishing between "not a directory" and "error accessing path," potentially masking legitimate infrastructure issues.

Would you like me to suggest a refactor using Result or Effect patterns that surfaces errors explicitly while maintaining backward compatibility?

packages/core/src/registry/embedded.ts (1)

14-48: Make EmbeddedReadFailed fully immutable (minor typing polish).
_tag is readonly but name/cause are not; consider marking all fields readonly for consistency with other error variants.

 export type EmbeddedReadFailed = {
   readonly _tag: "EmbeddedReadFailed";
-  name: string;
-  cause: unknown;
+  readonly name: string;
+  readonly cause: unknown;
 };
packages/core/src/registry/install.ts (1)

147-149: Consider not swallowing stat() errors other than ENOENT.
pathExistsEffect currently treats any error as “false”; permission issues can later surface as confusing write/rename failures.

-function pathExistsEffect(p: string): Effect.Effect<boolean, never> {
-  return Effect.promise(() => stat(p).then(() => true).catch(() => false));
+function pathExistsEffect(p: string): Effect.Effect<boolean, InstallError> {
+  return Effect.tryPromise({
+    try: async () => {
+      await stat(p);
+      return true;
+    },
+    catch: (cause) => ioError("stat", cause, p),
+  }).pipe(
+    // If you want “missing => false”, map only ENOENT-like cases here (platform-specific)
+  );
 }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 354c238 and cc6c3dc.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (6)
  • package.json (1 hunks)
  • packages/cli/index.ts (5 hunks)
  • packages/core/src/registry/config-root.ts (1 hunks)
  • packages/core/src/registry/embedded.ts (3 hunks)
  • packages/core/src/registry/install.ts (5 hunks)
  • packages/core/src/registry/jsonc.ts (9 hunks)
🧰 Additional context used
📓 Path-based instructions (5)
package.json

📄 CodeRabbit inference engine (CLAUDE.md)

package.json: Use bun install instead of npm install, yarn install, or pnpm install for dependency management
Use bun run <script> instead of npm run <script>, yarn run <script>, or pnpm run <script> for running scripts defined in package.json

Files:

  • package.json
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx,js,jsx}: Use bun <file> instead of node <file> or ts-node <file> for running scripts
Bun automatically loads .env files, so don't use the dotenv package
Use bun:sqlite for SQLite database operations instead of better-sqlite3
Use Bun.redis for Redis operations instead of ioredis
Use Bun.sql for Postgres database operations instead of pg or postgres.js
Use built-in WebSocket instead of the ws package
Prefer Bun.file over node:fs readFile/writeFile methods for file operations
Use Bun.$ for shell commands instead of execa

Files:

  • packages/core/src/registry/install.ts
  • packages/core/src/registry/config-root.ts
  • packages/core/src/registry/jsonc.ts
  • packages/cli/index.ts
  • packages/core/src/registry/embedded.ts
**/*.{html,ts,tsx,css}

📄 CodeRabbit inference engine (CLAUDE.md)

Use bun build <file.html|file.ts|file.css> instead of webpack or esbuild for bundling

Files:

  • packages/core/src/registry/install.ts
  • packages/core/src/registry/config-root.ts
  • packages/core/src/registry/jsonc.ts
  • packages/cli/index.ts
  • packages/core/src/registry/embedded.ts
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx}: Use Bun.serve() with built-in WebSocket, HTTPS, and routing support instead of express
Use bun --hot to run TypeScript files with hot module reloading for development

Files:

  • packages/core/src/registry/install.ts
  • packages/core/src/registry/config-root.ts
  • packages/core/src/registry/jsonc.ts
  • packages/cli/index.ts
  • packages/core/src/registry/embedded.ts
**/*.{html,tsx,ts}

📄 CodeRabbit inference engine (CLAUDE.md)

Use HTML imports with Bun.serve() for frontend development instead of vite

Files:

  • packages/core/src/registry/install.ts
  • packages/core/src/registry/config-root.ts
  • packages/core/src/registry/jsonc.ts
  • packages/cli/index.ts
  • packages/core/src/registry/embedded.ts
🧠 Learnings (8)
📚 Learning: 2025-12-04T21:23:25.944Z
Learnt from: CR
Repo: better-slop/ocx PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-04T21:23:25.944Z
Learning: Applies to **/*.{ts,tsx} : Use `bun --hot` to run TypeScript files with hot module reloading for development

Applied to files:

  • package.json
📚 Learning: 2025-12-04T21:23:25.944Z
Learnt from: CR
Repo: better-slop/ocx PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-04T21:23:25.944Z
Learning: Applies to **/*.{ts,tsx,js,jsx} : Use `bun <file>` instead of `node <file>` or `ts-node <file>` for running scripts

Applied to files:

  • package.json
📚 Learning: 2025-12-04T21:23:25.944Z
Learnt from: CR
Repo: better-slop/ocx PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-04T21:23:25.944Z
Learning: Applies to **/*.{html,ts,tsx,css} : Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild` for bundling

Applied to files:

  • package.json
📚 Learning: 2025-12-04T21:23:25.944Z
Learnt from: CR
Repo: better-slop/ocx PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-04T21:23:25.944Z
Learning: Applies to package.json : Use `bun run <script>` instead of `npm run <script>`, `yarn run <script>`, or `pnpm run <script>` for running scripts defined in package.json

Applied to files:

  • package.json
📚 Learning: 2025-12-04T21:23:25.944Z
Learnt from: CR
Repo: better-slop/ocx PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-04T21:23:25.944Z
Learning: Applies to **/*.{ts,tsx,js,jsx} : Use `Bun.$` for shell commands instead of `execa`

Applied to files:

  • package.json
📚 Learning: 2025-12-04T21:23:25.944Z
Learnt from: CR
Repo: better-slop/ocx PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-04T21:23:25.944Z
Learning: Applies to **/*.{ts,tsx,js,jsx} : Bun automatically loads .env files, so don't use the dotenv package

Applied to files:

  • package.json
📚 Learning: 2025-12-04T21:23:25.944Z
Learnt from: CR
Repo: better-slop/ocx PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-04T21:23:25.944Z
Learning: Applies to package.json : Use `bun install` instead of `npm install`, `yarn install`, or `pnpm install` for dependency management

Applied to files:

  • package.json
📚 Learning: 2025-12-04T21:23:25.944Z
Learnt from: CR
Repo: better-slop/ocx PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-04T21:23:25.944Z
Learning: Applies to **/*.{test,spec}.{ts,tsx} : Use `bun:test` with `test` and `expect` functions for unit testing in TypeScript

Applied to files:

  • package.json
🧬 Code graph analysis (3)
packages/core/src/registry/jsonc.ts (2)
packages/core/src/registry/result.ts (3)
  • Result (13-13)
  • err (19-21)
  • ok (15-17)
packages/core/src/registry/index.ts (2)
  • Result (15-15)
  • JSONCError (11-11)
packages/cli/index.ts (3)
packages/core/src/registry/types.ts (1)
  • InstallPlan (34-61)
packages/core/src/registry/resolve.ts (1)
  • resolveRegistryTree (59-64)
packages/core/src/registry/result.ts (2)
  • isErr (27-29)
  • err (19-21)
packages/core/src/registry/embedded.ts (1)
packages/core/src/registry/types.ts (1)
  • RegistryItemV1 (14-30)
🔇 Additional comments (5)
packages/core/src/registry/embedded.ts (1)

20-48: Effect pipeline looks solid; verify Effect.tryPromise mapping semantics on your Effect version.
This is a clean conversion from throw/await to typed failure. Just ensure Effect.tryPromise({ try, catch }) behaves as expected in effect@3.19.11 (esp. what counts as “caught” rejections vs sync throws).

package.json (1)

10-16: Dependency catalog additions: verify workspace resolution + runtime compatibility.
Please confirm the workspace “catalog:” resolution produces the intended effect@3.19.11 (and that it’s compatible with your Bun/TS setup).

packages/cli/index.ts (1)

85-145: Result-driven CLI flow is clear and consistent.
The isErr early-returns + centralized exitWithError keeps control flow straightforward and avoids try/catch sprawl.

packages/core/src/registry/jsonc.ts (1)

14-23: Nice Result-based refactor; verify downstream callers handle Err everywhere.
The internal helpers + explicit propagation look consistent; just make sure every consumer of getTopLevelJsoncPropertyValueText/upsertTopLevelJsoncProperty now checks _tag (no lingering throw/assume-success paths).

Also applies to: 80-297, 414-496

packages/core/src/registry/install.ts (1)

256-289: Good fix: don’t swallow config read errors anymore.
This addresses the earlier “.catch(() => "{}")” pitfall by only treating a missing file as {} and mapping other failures to IOError.

Comment on lines +31 to +83
function formatError(error: ResolveRegistryTreeError | InstallError): string {
switch (error._tag) {
case "DependencyCycle":
return `Registry dependency cycle detected at ${error.at}`;

case "MissingSpec":
return "Missing registry item spec";

case "UnknownSpec":
return `Unknown registry spec: ${error.spec} (try embedded item, URL, or path to .json)`;

case "EmbeddedReadFailed":
return `Failed to read embedded item ${error.name}: ${stringifyCause(error.cause)}`;

case "FetchFailed":
return error.status === undefined
? `Failed to fetch registry item: ${error.url}${error.cause ? ` (${stringifyCause(error.cause)})` : ""}`
: `Failed to fetch registry item: ${error.url} (${error.status})`;

case "FileReadFailed":
return `Failed to read file: ${error.path} (${stringifyCause(error.cause)})`;

case "JSONParseFailed":
return `Failed to parse JSON from ${error.source}: ${stringifyCause(error.cause)}`;

case "RegistryItemParseError":
return error.message;

case "NoPlans":
return "No install plans to apply";

case "MissingConfigRoot":
return "Missing config root";

case "InvalidRegistryFilePath":
return `Invalid registry file path ${error.path}: ${error.message}`;

case "WriteOutsideTargetDir":
return `Refusing to write outside target dir: ${error.path}`;

case "TargetAlreadyExists":
return `Target already exists: ${error.path} (use --overwrite)`;

case "IOError":
return `I/O error (${error.operation})${error.path ? `: ${error.path}` : ""} (${stringifyCause(error.cause)})`;

case "PostinstallFailed":
return `Postinstall command failed (${error.code}): ${error.cmd}`;

case "JSONCError":
return error.message;
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Add an exhaustiveness guard (prevents silent “undefined” on new error variants).
If a new _tag is added upstream, formatError can fall through and return undefined, producing confusing CLI output.

 function formatError(error: ResolveRegistryTreeError | InstallError): string {
   switch (error._tag) {
     ...
     case "JSONCError":
       return error.message;
+    default: {
+      const _exhaustive: never = error;
+      return `Unexpected error: ${String((_exhaustive as any)?._tag ?? "unknown")}`;
+    }
   }
 }
📝 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
function formatError(error: ResolveRegistryTreeError | InstallError): string {
switch (error._tag) {
case "DependencyCycle":
return `Registry dependency cycle detected at ${error.at}`;
case "MissingSpec":
return "Missing registry item spec";
case "UnknownSpec":
return `Unknown registry spec: ${error.spec} (try embedded item, URL, or path to .json)`;
case "EmbeddedReadFailed":
return `Failed to read embedded item ${error.name}: ${stringifyCause(error.cause)}`;
case "FetchFailed":
return error.status === undefined
? `Failed to fetch registry item: ${error.url}${error.cause ? ` (${stringifyCause(error.cause)})` : ""}`
: `Failed to fetch registry item: ${error.url} (${error.status})`;
case "FileReadFailed":
return `Failed to read file: ${error.path} (${stringifyCause(error.cause)})`;
case "JSONParseFailed":
return `Failed to parse JSON from ${error.source}: ${stringifyCause(error.cause)}`;
case "RegistryItemParseError":
return error.message;
case "NoPlans":
return "No install plans to apply";
case "MissingConfigRoot":
return "Missing config root";
case "InvalidRegistryFilePath":
return `Invalid registry file path ${error.path}: ${error.message}`;
case "WriteOutsideTargetDir":
return `Refusing to write outside target dir: ${error.path}`;
case "TargetAlreadyExists":
return `Target already exists: ${error.path} (use --overwrite)`;
case "IOError":
return `I/O error (${error.operation})${error.path ? `: ${error.path}` : ""} (${stringifyCause(error.cause)})`;
case "PostinstallFailed":
return `Postinstall command failed (${error.code}): ${error.cmd}`;
case "JSONCError":
return error.message;
}
}
function formatError(error: ResolveRegistryTreeError | InstallError): string {
switch (error._tag) {
case "DependencyCycle":
return `Registry dependency cycle detected at ${error.at}`;
case "MissingSpec":
return "Missing registry item spec";
case "UnknownSpec":
return `Unknown registry spec: ${error.spec} (try embedded item, URL, or path to .json)`;
case "EmbeddedReadFailed":
return `Failed to read embedded item ${error.name}: ${stringifyCause(error.cause)}`;
case "FetchFailed":
return error.status === undefined
? `Failed to fetch registry item: ${error.url}${error.cause ? ` (${stringifyCause(error.cause)})` : ""}`
: `Failed to fetch registry item: ${error.url} (${error.status})`;
case "FileReadFailed":
return `Failed to read file: ${error.path} (${stringifyCause(error.cause)})`;
case "JSONParseFailed":
return `Failed to parse JSON from ${error.source}: ${stringifyCause(error.cause)}`;
case "RegistryItemParseError":
return error.message;
case "NoPlans":
return "No install plans to apply";
case "MissingConfigRoot":
return "Missing config root";
case "InvalidRegistryFilePath":
return `Invalid registry file path ${error.path}: ${error.message}`;
case "WriteOutsideTargetDir":
return `Refusing to write outside target dir: ${error.path}`;
case "TargetAlreadyExists":
return `Target already exists: ${error.path} (use --overwrite)`;
case "IOError":
return `I/O error (${error.operation})${error.path ? `: ${error.path}` : ""} (${stringifyCause(error.cause)})`;
case "PostinstallFailed":
return `Postinstall command failed (${error.code}): ${error.cmd}`;
case "JSONCError":
return error.message;
default: {
const _exhaustive: never = error;
return `Unexpected error: ${String((_exhaustive as any)?._tag ?? "unknown")}`;
}
}
}
🤖 Prompt for AI Agents
In packages/cli/index.ts around lines 31 to 83, the switch over error._tag is
not exhaustive so adding new error variants upstream can cause formatError to
return undefined; add an exhaustiveness guard as the final branch (either a
default case or an assertNever-style function) that throws or logs a clear error
and/or returns a generic fallback string including the unexpected tag and
serialized error so the CLI never returns undefined and new error variants are
obvious during runtime.

Comment on lines +292 to 322
function runPostinstallEffect(plan: InstallPlan): Effect.Effect<void, InstallError> {
if (!plan.postinstall) return Effect.void;

return Effect.forEach(
plan.postinstall.commands,
(cmd) =>
Effect.tryPromise({
try: async () => {
const proc = Bun.spawn({
cmd: ["bash", "-lc", cmd],
cwd: plan.postinstall?.cwd,
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
env: {
...process.env,
},
});

return await proc.exited;
},
catch: (cause): InstallError => ioError("postinstall", cause),
}).pipe(
Effect.flatMap((code) => {
if (code === 0) return Effect.void;
return Effect.fail(postinstallFailed(cmd, code));
}),
),
{ concurrency: 1 },
).pipe(Effect.asVoid);
}
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Dec 13, 2025

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

wc -l packages/core/src/registry/install.ts

Repository: better-slop/ocx

Length of output: 100


🏁 Script executed:

head -330 packages/core/src/registry/install.ts | tail -40

Repository: better-slop/ocx

Length of output: 1151


🏁 Script executed:

sed -n '285,330p' packages/core/src/registry/install.ts

Repository: better-slop/ocx

Length of output: 1341


🏁 Script executed:

sed -n '346,365p' packages/core/src/registry/install.ts

Repository: better-slop/ocx

Length of output: 614


🏁 Script executed:

rg -n "type InstallError|class InstallError" packages/core/src/registry/install.ts -A 20

Repository: better-slop/ocx

Length of output: 41


🏁 Script executed:

rg -n "postinstall" packages/core/src/registry/install.ts

Repository: better-slop/ocx

Length of output: 806


🏁 Script executed:

rg -n "InstallError" packages/core/src/registry/install.ts | head -20

Repository: better-slop/ocx

Length of output: 1488


🏁 Script executed:

rg -B5 -A10 "type.*InstallError\s*=" packages/core/src/registry/install.ts

Repository: better-slop/ocx

Length of output: 41


🏁 Script executed:

fd -t f "*.ts" packages/core/src | xargs rg -l "InstallError" | head -5

Repository: better-slop/ocx

Length of output: 492


🏁 Script executed:

rg -B2 -A15 "^export type InstallError|^type InstallError" packages/core/src/

Repository: better-slop/ocx

Length of output: 903


🏁 Script executed:

python3 << 'EOF'
import os
import pathlib

# Test path.relative() behavior equivalent
def test_path_escape(root, user_input):
    # Simulate what the code does
    resolved = os.path.normpath(os.path.join(root, user_input))
    relative = os.path.relpath(resolved, root)
    escapes = relative.startswith("..")
    
    print(f"Input: {user_input!r}")
    print(f"  Root: {root}")
    print(f"  Resolved: {resolved}")
    print(f"  Relative: {relative}")
    print(f"  Escapes: {escapes}")
    print()

root = "/home/user/project"

# Test cases
test_path_escape(root, ".")
test_path_escape(root, "subdir")
test_path_escape(root, "../..")
test_path_escape(root, "../../etc/passwd")
test_path_escape(root, "/etc/passwd")
test_path_escape(root, "/home/user/project/safe")

EOF

Repository: better-slop/ocx

Length of output: 145


🏁 Script executed:

rg -n "postinstall" packages/core/src/registry/install.ts -B2 -A2 | head -40

Repository: better-slop/ocx

Length of output: 1263


🏁 Script executed:

find packages -name "*.test.ts" -o -name "*.spec.ts" | xargs rg -l "postinstall" 2>/dev/null | head -5

Repository: better-slop/ocx

Length of output: 216


🏁 Script executed:

rg -B5 -A10 "interface InstallPlan|type InstallPlan" packages/core/src/registry/install.ts

Repository: better-slop/ocx

Length of output: 41


Restrict postinstall.cwd to prevent path traversal outside project root.

The current code at line 356 allows postinstall.cwd values like ../../.. or absolute paths to escape configRoot.rootDir, enabling postinstall hooks to execute outside the project when using remote manifests. Add validation using path.relative() to ensure the resolved path stays within the project boundary:

   const postinstall = item.postinstall
     ? {
         commands: item.postinstall.commands,
-        cwd: path.resolve(configRoot.rootDir, item.postinstall.cwd ?? "."),
+        cwd: (() => {
+          const cwdAbs = path.resolve(configRoot.rootDir, item.postinstall.cwd ?? ".");
+          const rel = path.relative(configRoot.rootDir, cwdAbs);
+          if (rel.startsWith("..") || path.isAbsolute(rel)) {
+            throw new Error(`postinstall.cwd must stay within project root: ${item.postinstall.cwd ?? "."}`);
+          }
+          return cwdAbs;
+        })(),
       }
     : null;

Alternatively, add a dedicated InstallError variant (e.g., PostinstallCwdEscape) and return it from planInstalls instead of throwing.

Also applies to: line 356

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@coderabbitai we have different plans for this implementation, likely one that includes a verified registry list that is allowed postinstall. otherwise, we deny

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@cau1k, thanks for clarifying! A verified registry list with postinstall allowlisting is a solid security model — it centralizes trust at the registry level rather than validating individual paths. That approach will make the security boundary clearer and easier to audit.

Looking forward to seeing how the registry verification is implemented when you get to it. Let me know if you'd like me to review that part when it's ready! 🐰

🐰💭
(thinking about
 verified
 registries...)

✏️ Learnings added
Learnt from: cau1k
Repo: better-slop/ocx PR: 6
File: packages/core/src/registry/install.ts:292-322
Timestamp: 2025-12-13T05:48:32.141Z
Learning: In packages/core/src/registry/install.ts, the project plans to implement a verified registry list approach for postinstall security. Registries on the verified list will be allowed to execute postinstall hooks, while unverified registries will be denied. This is the preferred security model over individual path validation for postinstall.cwd.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Comment on lines +406 to +432
return Effect.gen(function* () {
yield* mkdirEffect(root.opencodeDir);

const exists = await pathExists(plan.item.targetDir);
if (exists) {
if (!overwrite) {
throw new Error(
`Target already exists: ${plan.item.targetDir} (use --overwrite)`,
);
}
await rm(plan.item.targetDir, { recursive: true, force: true });
for (const plan of plans) {
const dir = yield* stageDirEffect(plan, opts.overwrite);
wrote.push(dir);
}

await rename(tmp, plan.item.targetDir);
return plan.item.targetDir;
} catch (err) {
await rm(tmp, { recursive: true, force: true });
throw err;
}
}

async function updateConfig(
path: string,
plans: InstallPlan[],
): Promise<void> {
const text = await Bun.file(path).text().catch(() => "{}");
yield* mkdirEffect(path.dirname(root.configPath));
yield* updateConfigEffect(root.configPath, plans);

const cfg = parseExistingOCXManagedConfig(text);
let ran = false;

for (const plan of plans) {
const { kind, name, source, entryRel } = plan.item;
const dir = entryRel.split("/").slice(0, -1).join("/");
if (opts.allowPostinstall) {
for (const plan of plans) {
if (!plan.postinstall) continue;
ran = true;
yield* runPostinstallEffect(plan);
}
}

cfg.items[kind][name] = {
source,
dir,
entry: entryRel,
...(plan.postinstall ? { postinstall: plan.postinstall } : {}),
return {
wroteFiles: wrote,
editedConfigPath: root.configPath,
ranPostinstall: ran,
};
}

const updated = upsertTopLevelJsoncProperty(text, "ocx", cfg);
await writeAtomic(path, updated);
}

async function runPostinstall(plan: InstallPlan): Promise<void> {
if (!plan.postinstall) return;

for (const cmd of plan.postinstall.commands) {
const proc = Bun.spawn({
cmd: ["bash", "-lc", cmd],
cwd: plan.postinstall.cwd,
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
env: {
...process.env,
},
});

const code = await proc.exited;
if (code !== 0) {
throw new Error(`Postinstall command failed (${code}): ${cmd}`);
}
}
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

ranPostinstall should reflect “executed at least one command”.
Right now ran = true for any plan.postinstall, even if commands is empty.

-    if (opts.allowPostinstall) {
+    if (opts.allowPostinstall) {
       for (const plan of plans) {
-        if (!plan.postinstall) continue;
-        ran = true;
+        if (!plan.postinstall || plan.postinstall.commands.length === 0) continue;
+        ran = true;
         yield* runPostinstallEffect(plan);
       }
     }
📝 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
return Effect.gen(function* () {
yield* mkdirEffect(root.opencodeDir);
const exists = await pathExists(plan.item.targetDir);
if (exists) {
if (!overwrite) {
throw new Error(
`Target already exists: ${plan.item.targetDir} (use --overwrite)`,
);
}
await rm(plan.item.targetDir, { recursive: true, force: true });
for (const plan of plans) {
const dir = yield* stageDirEffect(plan, opts.overwrite);
wrote.push(dir);
}
await rename(tmp, plan.item.targetDir);
return plan.item.targetDir;
} catch (err) {
await rm(tmp, { recursive: true, force: true });
throw err;
}
}
async function updateConfig(
path: string,
plans: InstallPlan[],
): Promise<void> {
const text = await Bun.file(path).text().catch(() => "{}");
yield* mkdirEffect(path.dirname(root.configPath));
yield* updateConfigEffect(root.configPath, plans);
const cfg = parseExistingOCXManagedConfig(text);
let ran = false;
for (const plan of plans) {
const { kind, name, source, entryRel } = plan.item;
const dir = entryRel.split("/").slice(0, -1).join("/");
if (opts.allowPostinstall) {
for (const plan of plans) {
if (!plan.postinstall) continue;
ran = true;
yield* runPostinstallEffect(plan);
}
}
cfg.items[kind][name] = {
source,
dir,
entry: entryRel,
...(plan.postinstall ? { postinstall: plan.postinstall } : {}),
return {
wroteFiles: wrote,
editedConfigPath: root.configPath,
ranPostinstall: ran,
};
}
const updated = upsertTopLevelJsoncProperty(text, "ocx", cfg);
await writeAtomic(path, updated);
}
async function runPostinstall(plan: InstallPlan): Promise<void> {
if (!plan.postinstall) return;
for (const cmd of plan.postinstall.commands) {
const proc = Bun.spawn({
cmd: ["bash", "-lc", cmd],
cwd: plan.postinstall.cwd,
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
env: {
...process.env,
},
});
const code = await proc.exited;
if (code !== 0) {
throw new Error(`Postinstall command failed (${code}): ${cmd}`);
}
}
});
return Effect.gen(function* () {
yield* mkdirEffect(root.opencodeDir);
for (const plan of plans) {
const dir = yield* stageDirEffect(plan, opts.overwrite);
wrote.push(dir);
}
yield* mkdirEffect(path.dirname(root.configPath));
yield* updateConfigEffect(root.configPath, plans);
let ran = false;
if (opts.allowPostinstall) {
for (const plan of plans) {
if (!plan.postinstall || plan.postinstall.commands.length === 0) continue;
ran = true;
yield* runPostinstallEffect(plan);
}
}
return {
wroteFiles: wrote,
editedConfigPath: root.configPath,
ranPostinstall: ran,
};
});
🤖 Prompt for AI Agents
In packages/core/src/registry/install.ts around lines 406 to 432, the
ranPostinstall flag is set to true for any plan that has a postinstall object
even if that postinstall has no commands; change the loop to only treat
postinstall as executed when it actually contains commands (e.g., skip plans
where !plan.postinstall or plan.postinstall.commands is empty), set ran = true
only when you will run the commands, and then yield* runPostinstallEffect(plan)
so ranPostinstall accurately reflects "executed at least one command."

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.

2 participants