From b5e4d8d0004691c0ca5852dfcbccc5af2c80455e Mon Sep 17 00:00:00 2001 From: cau1k Date: Fri, 12 Dec 2025 23:39:59 -0500 Subject: [PATCH 1/3] feat: registry --- packages/core/src/registry/errors.ts | 32 ++++++++++++++ packages/core/src/registry/result.ts | 63 ++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 packages/core/src/registry/errors.ts create mode 100644 packages/core/src/registry/result.ts diff --git a/packages/core/src/registry/errors.ts b/packages/core/src/registry/errors.ts new file mode 100644 index 0000000..d884148 --- /dev/null +++ b/packages/core/src/registry/errors.ts @@ -0,0 +1,32 @@ +export type JSONCError = { + _tag: "JSONCError"; + message: string; +}; + +export type RegistryItemParseError = { + _tag: "RegistryItemParseError"; + message: string; +}; + +export type FetchRegistryItemError = + | { _tag: "MissingSpec" } + | { _tag: "UnknownSpec"; spec: string } + | { _tag: "EmbeddedReadFailed"; name: string; cause: unknown } + | { _tag: "FetchFailed"; url: string; status?: number; cause?: unknown } + | { _tag: "FileReadFailed"; path: string; cause: unknown } + | { _tag: "JSONParseFailed"; source: string; cause: unknown } + | RegistryItemParseError; + +export type ResolveRegistryTreeError = + | { _tag: "DependencyCycle"; at: string } + | FetchRegistryItemError; + +export type InstallError = + | { _tag: "NoPlans" } + | { _tag: "MissingConfigRoot" } + | { _tag: "InvalidRegistryFilePath"; path: string; message: string } + | { _tag: "WriteOutsideTargetDir"; path: string } + | { _tag: "TargetAlreadyExists"; path: string } + | { _tag: "IOError"; operation: string; path?: string; cause: unknown } + | { _tag: "PostinstallFailed"; cmd: string; code: number } + | JSONCError; diff --git a/packages/core/src/registry/result.ts b/packages/core/src/registry/result.ts new file mode 100644 index 0000000..29154e7 --- /dev/null +++ b/packages/core/src/registry/result.ts @@ -0,0 +1,63 @@ +export type Ok = { + _tag: "Ok"; + value: T; +}; + +export type Err = { + _tag: "Err"; + error: E; +}; + +export type Result = Ok | Err; + +export function ok(value: T): Ok { + return { _tag: "Ok", value }; +} + +export function err(error: E): Err { + return { _tag: "Err", error }; +} + +export function isOk(res: Result): res is Ok { + return res._tag === "Ok"; +} + +export function isErr(res: Result): res is Err { + return res._tag === "Err"; +} + +export function map(res: Result, fn: (value: T) => U): Result { + if (res._tag === "Err") return res; + return ok(fn(res.value)); +} + +export function flatMap( + res: Result, + fn: (value: T) => Result, +): Result { + if (res._tag === "Err") return res; + return fn(res.value); +} + +export function mapError( + res: Result, + fn: (error: E1) => E2, +): Result { + if (res._tag === "Ok") return res; + return err(fn(res.error)); +} + +export function trySync(fn: () => T, onError: (cause: unknown) => E): Result { + try { + return ok(fn()); + } catch (cause) { + return err(onError(cause)); + } +} + +export async function tryPromise( + fn: () => Promise, + onError: (cause: unknown) => E, +): Promise> { + return fn().then(ok).catch((cause) => err(onError(cause))); +} From 354c23849e8c11d69a7015a2faf0ecefab2d8a4a Mon Sep 17 00:00:00 2001 From: cau1k Date: Sat, 13 Dec 2025 00:13:02 -0500 Subject: [PATCH 2/3] refac: registry error flow via Effect --- bun.lock | 12 +- package.json | 3 +- packages/cli/index.ts | 99 ++++- packages/core/package.json | 3 +- packages/core/src/registry/config-root.ts | 7 +- packages/core/src/registry/embedded.ts | 31 +- packages/core/src/registry/index.ts | 20 +- packages/core/src/registry/install.ts | 499 ++++++++++++++-------- packages/core/src/registry/jsonc.ts | 137 ++++-- packages/core/src/registry/registry.ts | 264 ++++++++---- packages/core/src/registry/resolve.ts | 68 ++- packages/core/src/registry/result.ts | 26 +- 12 files changed, 768 insertions(+), 401 deletions(-) diff --git a/bun.lock b/bun.lock index 543bf35..a4075ce 100644 --- a/bun.lock +++ b/bun.lock @@ -72,6 +72,7 @@ "version": "0.1.0", "dependencies": { "@opencode-ai/plugin": "catalog:", + "effect": "catalog:", }, "devDependencies": { "@better-slop-internals/tsconfig": "workspace:*", @@ -108,6 +109,7 @@ "catalog": { "@opencode-ai/plugin": "latest", "@types/bun": "latest", + "effect": "latest", "handlebars": "4.7.8", "typescript": "5.9.3", }, @@ -980,6 +982,8 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + "effect": ["effect@3.19.11", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-UTEj3c1s41Ha3uzSPKKvFBZaDjZ8ez00Q2NYWVm2mKh2LXeX8j6LTg1HcQHnmdUhOjr79KHmhVWYB/zbegLO1A=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], @@ -1042,6 +1046,8 @@ "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], + "fast-content-type-parse": ["fast-content-type-parse@2.0.1", "", {}, "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q=="], "fast-json-patch": ["fast-json-patch@3.1.1", "", {}, "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ=="], @@ -1448,6 +1454,8 @@ "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + "qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="], "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], @@ -1888,8 +1896,6 @@ "@aws-sdk/middleware-signing/@smithy/util-middleware": ["@smithy/util-middleware@2.2.0", "", { "dependencies": { "@smithy/types": "^2.12.0", "tslib": "^2.6.2" } }, "sha512-L1qpleXf9QD6LwLCJ5jddGkgWyuSvWBkJwWAZ6kFkdifdso+sk3L3O1HdmPvCdnCK3IS4qWyPxev01QMnfHSBw=="], - "@better-slop/ocx-librarian-plugin/@opencode-ai/plugin": ["@opencode-ai/plugin@1.0.150", "", { "dependencies": { "@opencode-ai/sdk": "1.0.150", "zod": "4.1.8" } }, "sha512-XmY3yydk120GBv2KeLxSZlElFx4Zx9TYLa3bS9X1TxXot42UeoMLEi3Xa46yboYnWwp4bC9Fu+Gd1E7hypG8Jw=="], - "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], "@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], @@ -2168,8 +2174,6 @@ "@aws-sdk/middleware-signing/@smithy/signature-v4/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], - "@better-slop/ocx-librarian-plugin/@opencode-ai/plugin/@opencode-ai/sdk": ["@opencode-ai/sdk@1.0.150", "", {}, "sha512-Nz9Di8UD/GK01w3N+jpiGNB733pYkNY8RNLbuE/HUxEGSP5apbXBY0IdhbW7859sXZZK38kF1NqOx4UxwBf4Bw=="], - "@dotenvx/dotenvx/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], "@dotenvx/dotenvx/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], diff --git a/package.json b/package.json index 96f6b44..b5cce20 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "@opencode-ai/plugin": "latest", "@types/bun": "latest", "typescript": "5.9.3", - "handlebars": "4.7.8" + "handlebars": "4.7.8", + "effect": "latest" }, "scripts": { "compile:scripts": "bun build scripts/* --compile --out dist/scripts", diff --git a/packages/cli/index.ts b/packages/cli/index.ts index a577cf1..53f0811 100644 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -3,10 +3,13 @@ import { hideBin } from "yargs/helpers"; import type { ArgumentsCamelCase } from "yargs"; import { applyInstallPlans, + isErr, listEmbeddedItems, planInstalls, resolveConfigRoot, resolveRegistryTree, + type InstallError, + type ResolveRegistryTreeError, } from "@better-slop/core/registry"; type GlobalOpts = { @@ -19,12 +22,72 @@ type AddOpts = GlobalOpts & { "allow-postinstall": boolean; }; -type Plan = ReturnType[number]; +function stringifyCause(cause: unknown): string { + if (cause instanceof Error) return cause.message; + return String(cause); +} -function printHooks(plans: Plan[]): void { - const with_hooks = plans.filter( - (p) => p.postinstall && p.postinstall.commands.length > 0, - ); +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 exitWithError(error: ResolveRegistryTreeError | InstallError): void { + console.error(formatError(error)); + process.exitCode = 1; +} + +function printHooks(plans: Array<{ item: { kind: string; name: string }; postinstall: null | { commands: string[] } }>): void { + const with_hooks = plans.filter((p) => p.postinstall && p.postinstall.commands.length > 0); if (with_hooks.length === 0) return; console.log("\nPostinstall hooks detected (skipped unless --allow-postinstall):"); @@ -38,13 +101,17 @@ function printHooks(plans: Plan[]): void { async function runAdd(argv: ArgumentsCamelCase): Promise { const root = await resolveConfigRoot(argv.cwd); - const resolved = await resolveRegistryTree(argv.spec, { cwd: argv.cwd }); - const plans = planInstalls(resolved, root); + + const resolvedRes = await resolveRegistryTree(argv.spec, { cwd: argv.cwd }); + if (isErr(resolvedRes)) return exitWithError(resolvedRes.error); + + const plansRes = planInstalls(resolvedRes.value, root); + if (isErr(plansRes)) return exitWithError(plansRes.error); + + const plans = plansRes.value; const files = plans.reduce((sum, p) => sum + p.writes.length, 0); - const hooks = plans.some( - (p) => p.postinstall && p.postinstall.commands.length > 0, - ); + const hooks = plans.some((p) => p.postinstall && p.postinstall.commands.length > 0); console.log(`Config: ${root.configPath}`); console.log(`Install root: ${root.opencodeDir} (${root.kind})`); @@ -62,11 +129,15 @@ async function runAdd(argv: ArgumentsCamelCase): Promise { console.log("\nRe-run with --allow-postinstall to execute hooks."); } - const result = await applyInstallPlans(plans, { + const applyRes = await applyInstallPlans(plans, { overwrite: argv.overwrite, allowPostinstall: argv.allowPostinstall, }); + if (isErr(applyRes)) return exitWithError(applyRes.error); + + const result = applyRes.value; + console.log(`\nWrote ${result.wroteFiles.length} item(s).`); console.log(`Updated config: ${result.editedConfigPath}`); console.log(`Postinstall: ${result.ranPostinstall ? "ran" : "skipped"}`); @@ -144,10 +215,8 @@ const cli = yargs(hideBin(process.argv)) process.exitCode = 1; }); -try { - await cli.parseAsync(); -} catch (err) { +await cli.parseAsync().catch((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); console.error(msg); process.exitCode = 1; -} +}); diff --git a/packages/core/package.json b/packages/core/package.json index 0ffa454..575f9db 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -13,7 +13,8 @@ } }, "dependencies": { - "@opencode-ai/plugin": "catalog:" + "@opencode-ai/plugin": "catalog:", + "effect": "catalog:" }, "devDependencies": { "@better-slop-internals/tsconfig": "workspace:*", diff --git a/packages/core/src/registry/config-root.ts b/packages/core/src/registry/config-root.ts index 02b3aea..3cb3105 100644 --- a/packages/core/src/registry/config-root.ts +++ b/packages/core/src/registry/config-root.ts @@ -4,12 +4,7 @@ import { stat } from "node:fs/promises"; import type { ConfigRoot } from "./types"; async function isDir(p: string): Promise { - try { - const s = await stat(p); - return s.isDirectory(); - } catch { - return false; - } + return stat(p).then((s) => s.isDirectory()).catch(() => false); } function normalize(p: string): string { diff --git a/packages/core/src/registry/embedded.ts b/packages/core/src/registry/embedded.ts index cf39989..0f04a6f 100644 --- a/packages/core/src/registry/embedded.ts +++ b/packages/core/src/registry/embedded.ts @@ -1,3 +1,4 @@ +import { Effect } from "effect"; import type { RegistryItemV1 } from "./types"; const TOOLS = { @@ -10,18 +11,28 @@ export function listEmbeddedItems(): EmbeddedName[] { return Object.keys(TOOLS) as EmbeddedName[]; } -export async function getEmbeddedRegistryItem( +export type EmbeddedReadFailed = { + _tag: "EmbeddedReadFailed"; + name: string; + cause: unknown; +}; + +export function getEmbeddedRegistryItemEffect( name: string, -): Promise { +): Effect.Effect { const s = name.trim(); const key = s.includes("/") ? (s.split("/").at(-1) ?? "") : s; - if (key in TOOLS) { - const k = key as EmbeddedName; - const url = TOOLS[k]; - const content = await Bun.file(url).text(); + if (!(key in TOOLS)) return Effect.succeed(null); + + const k = key as EmbeddedName; + const url = TOOLS[k]; - return { + return Effect.tryPromise({ + try: () => Bun.file(url).text(), + catch: (cause): EmbeddedReadFailed => ({ _tag: "EmbeddedReadFailed", name: k, cause }), + }).pipe( + Effect.map((content) => ({ schemaVersion: 1, kind: "tool", name: k, @@ -33,8 +44,6 @@ export async function getEmbeddedRegistryItem( }, ], entry: "index.ts", - }; - } - - return null; + })), + ); } diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 2b50c91..4ac55a3 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -1,14 +1,24 @@ +export { applyInstallPlans, defaultHomeDir, planInstalls } from "./install"; export { resolveConfigRoot } from "./config-root"; +export { listEmbeddedItems } from "./embedded"; export { fetchRegistryItem } from "./registry"; export { resolveRegistryTree } from "./resolve"; -export { planInstalls, applyInstallPlans, defaultHomeDir } from "./install"; -export { listEmbeddedItems } from "./embedded"; +export { isErr, isOk } from "./result"; export type { - OCXItemKind, + FetchRegistryItemError, + InstallError, + JSONCError, + RegistryItemParseError, + ResolveRegistryTreeError, +} from "./errors"; +export type { Err, Ok, Result } from "./result"; +export type { ResolvedItem } from "./resolve"; +export type { + ApplyInstallResult, ConfigRoot, + InstallPlan, + OCXItemKind, RegistryItem, RegistryItemV1, - InstallPlan, - ApplyInstallResult, } from "./types"; diff --git a/packages/core/src/registry/install.ts b/packages/core/src/registry/install.ts index e7f6881..34f565d 100644 --- a/packages/core/src/registry/install.ts +++ b/packages/core/src/registry/install.ts @@ -1,17 +1,12 @@ -import path from "node:path"; -import { mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises"; import os from "node:os"; -import type { - ApplyInstallResult, - ConfigRoot, - InstallPlan, - OCXItemKind, -} from "./types"; +import path from "node:path"; +import { chmod, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises"; +import { Effect } from "effect"; +import type { InstallError, JSONCError } from "./errors"; +import { getTopLevelJsoncPropertyValueText, upsertTopLevelJsoncProperty } from "./jsonc"; +import { err, ok, runEffect, toEffect, type Result } from "./result"; import type { ResolvedItem } from "./resolve"; -import { - getTopLevelJsoncPropertyValueText, - upsertTopLevelJsoncProperty, -} from "./jsonc"; +import type { ApplyInstallResult, ConfigRoot, InstallPlan, OCXItemKind } from "./types"; type InstalledOCXItem = { source: string; @@ -27,6 +22,38 @@ type OCXManagedConfig = { items: Record>; }; +function jsoncError(message: string): JSONCError { + return { _tag: "JSONCError", message }; +} + +function noPlans(): InstallError { + return { _tag: "NoPlans" }; +} + +function missingConfigRoot(): InstallError { + return { _tag: "MissingConfigRoot" }; +} + +function invalidRegistryFilePath(p: string, message: string): InstallError { + return { _tag: "InvalidRegistryFilePath", path: p, message }; +} + +function writeOutsideTargetDir(p: string): InstallError { + return { _tag: "WriteOutsideTargetDir", path: p }; +} + +function targetAlreadyExists(p: string): InstallError { + return { _tag: "TargetAlreadyExists", path: p }; +} + +function ioError(operation: string, cause: unknown, p?: string): InstallError { + return { _tag: "IOError", operation, path: p, cause }; +} + +function postinstallFailed(cmd: string, code: number): InstallError { + return { _tag: "PostinstallFailed", cmd, code }; +} + function emptyOCXManagedConfig(): OCXManagedConfig { return { items: { @@ -47,96 +74,281 @@ function ensureRecordMap(value: unknown): Record { return value as Record; } -function parseExistingOCXManagedConfig(text: string): OCXManagedConfig { - const val = getTopLevelJsoncPropertyValueText(text, "ocx"); - if (!val) return emptyOCXManagedConfig(); - - try { - const parsed: unknown = JSON.parse(val); - if (!isRecord(parsed)) return emptyOCXManagedConfig(); - - const raw = parsed.items; - const items = isRecord(raw) ? raw : {}; - - return { - items: { - tool: ensureRecordMap(items.tool), - agent: ensureRecordMap(items.agent), - command: ensureRecordMap(items.command), - themes: ensureRecordMap(items.themes), - }, - }; - } catch { - return emptyOCXManagedConfig(); - } +function stringifyCause(cause: unknown): string { + if (cause instanceof Error) return cause.message; + return String(cause); } -function normalizeRelPath(p: string): string { +function normalizeRelPath(p: string): Result { const norm = path.posix.normalize(p).replace(/^\.\//, ""); if (path.posix.isAbsolute(norm)) { - throw new Error(`Registry file path must be relative: ${p}`); + return err(invalidRegistryFilePath(p, "Registry file path must be relative")); } if (norm === ".." || norm.startsWith("../")) { - throw new Error(`Registry file path cannot escape target dir: ${p}`); + return err(invalidRegistryFilePath(p, "Registry file path cannot escape target dir")); } if (norm.length === 0) { - throw new Error("Registry file path cannot be empty"); + return err(invalidRegistryFilePath(p, "Registry file path cannot be empty")); } - return norm; + return ok(norm); } function computeDirRel(configRoot: ConfigRoot, kind: OCXItemKind, name: string): string { - const parts = configRoot.kind === "project" - ? [".opencode", kind, name] - : [kind, name]; + const parts = configRoot.kind === "project" ? [".opencode", kind, name] : [kind, name]; return parts.join("/"); } -async function pathExists(p: string): Promise { - try { - await stat(p); - return true; - } catch { - return false; - } +function toMode(mode: "0644" | "0755"): number { + if (mode === "0644") return 0o644; + return 0o755; +} + +function mkdirEffect(p: string): Effect.Effect { + return Effect.tryPromise({ + try: () => mkdir(p, { recursive: true }), + catch: (cause): InstallError => ioError("mkdir", cause, p), + }).pipe(Effect.asVoid); +} + +function mkdtempEffect(prefix: string): Effect.Effect { + return Effect.tryPromise({ + try: () => mkdtemp(prefix), + catch: (cause): InstallError => ioError("mkdtemp", cause, prefix), + }); +} + +function renameEffect(from: string, to: string): Effect.Effect { + return Effect.tryPromise({ + try: () => rename(from, to), + catch: (cause): InstallError => ioError("rename", cause, `${from} -> ${to}`), + }).pipe(Effect.asVoid); +} + +function rmEffect(p: string): Effect.Effect { + return Effect.tryPromise({ + try: () => rm(p, { recursive: true, force: true }), + catch: (cause): InstallError => ioError("rm", cause, p), + }).pipe(Effect.asVoid); +} + +function chmodEffect(p: string, mode: number): Effect.Effect { + return Effect.tryPromise({ + try: () => chmod(p, mode), + catch: (cause): InstallError => ioError("chmod", cause, p), + }).pipe(Effect.asVoid); +} + +function pathExistsEffect(p: string): Effect.Effect { + return Effect.promise(() => stat(p).then(() => true).catch(() => false)); +} + +function bunWriteEffect(p: string, content: string): Effect.Effect { + return Effect.tryPromise({ + try: () => Bun.write(p, content), + catch: (cause): InstallError => ioError("write", cause, p), + }).pipe(Effect.asVoid); +} + +function withTmpDirEffect( + parent: string, + prefix: string, + use: (tmp: string) => Effect.Effect, +): Effect.Effect { + return Effect.acquireUseRelease( + mkdtempEffect(path.join(parent, prefix)), + use, + (tmp) => rmEffect(tmp).pipe(Effect.catchAll(() => Effect.void)), + ); +} + +function writeAtomicEffect(dest: string, content: string): Effect.Effect { + const dir = path.dirname(dest); + + return mkdirEffect(dir).pipe( + Effect.zipRight( + withTmpDirEffect(dir, `.tmp-ocx-${Date.now()}-`, (tmp) => { + const file = path.join(tmp, "file"); + return bunWriteEffect(file, content).pipe(Effect.zipRight(renameEffect(file, dest))); + }), + ), + ); +} + +function stageDirEffect(plan: InstallPlan, overwrite: boolean): Effect.Effect { + const dir = path.dirname(plan.item.targetDir); + + return mkdirEffect(dir).pipe( + Effect.zipRight( + withTmpDirEffect(dir, `.tmp-ocx-${plan.item.name}-`, (tmp) => + Effect.gen(function* () { + for (const w of plan.writes) { + const rel = path.relative(plan.item.targetDir, w.path); + if (rel.startsWith("..") || path.isAbsolute(rel)) { + return yield* Effect.fail(writeOutsideTargetDir(w.path)); + } + + const dest = path.join(tmp, rel); + yield* mkdirEffect(path.dirname(dest)); + yield* bunWriteEffect(dest, w.content); + + if (w.mode) { + yield* chmodEffect(dest, toMode(w.mode)); + } + } + + const exists = yield* pathExistsEffect(plan.item.targetDir); + + if (exists && !overwrite) { + return yield* Effect.fail(targetAlreadyExists(plan.item.targetDir)); + } + + if (exists && overwrite) { + yield* rmEffect(plan.item.targetDir); + } + + yield* renameEffect(tmp, plan.item.targetDir); + return plan.item.targetDir; + }), + ), + ), + ); +} + +function parseExistingOCXManagedConfigEffect(text: string): Effect.Effect { + const ocxRes = getTopLevelJsoncPropertyValueText(text, "ocx"); + + return toEffect(ocxRes).pipe( + Effect.flatMap((val) => { + if (!val) return Effect.succeed(emptyOCXManagedConfig()); + + return Effect.try({ + try: () => JSON.parse(val) as unknown, + catch: (cause) => jsoncError(`Invalid JSON in ocx property: ${stringifyCause(cause)}`), + }).pipe( + Effect.flatMap((parsed) => { + if (!isRecord(parsed)) { + return Effect.fail(jsoncError("Invalid ocx config: expected object")); + } + + const raw = parsed.items; + const items = isRecord(raw) ? raw : {}; + + return Effect.succeed({ + items: { + tool: ensureRecordMap(items.tool), + agent: ensureRecordMap(items.agent), + command: ensureRecordMap(items.command), + themes: ensureRecordMap(items.themes), + }, + }); + }), + ); + }), + ); +} + +function updateConfigEffect(configPath: string, plans: InstallPlan[]): Effect.Effect { + return Effect.promise(() => Bun.file(configPath).text().catch(() => "{}" as const)).pipe( + Effect.flatMap((text) => + parseExistingOCXManagedConfigEffect(text).pipe( + Effect.flatMap((cfg) => + Effect.sync(() => { + for (const plan of plans) { + const { kind, name, source, entryRel } = plan.item; + const dir = entryRel.split("/").slice(0, -1).join("/"); + + cfg.items[kind][name] = { + source, + dir, + entry: entryRel, + ...(plan.postinstall ? { postinstall: plan.postinstall } : {}), + }; + } + + return cfg; + }), + ), + Effect.flatMap((cfg) => toEffect(upsertTopLevelJsoncProperty(text, "ocx", cfg))), + Effect.flatMap((updated) => writeAtomicEffect(configPath, updated)), + ), + ), + ); +} + +function runPostinstallEffect(plan: InstallPlan): Effect.Effect { + 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); } export function planInstalls( resolved: ResolvedItem[], configRoot: ConfigRoot, -): InstallPlan[] { - return resolved.map(({ item, source }) => { +): Result { + const plans: InstallPlan[] = []; + + for (const { item, source } of resolved) { const kindDir = path.join(configRoot.opencodeDir, item.kind); const targetDir = path.join(kindDir, item.name); const dirRel = computeDirRel(configRoot, item.kind, item.name); - const entryFile = normalizeRelPath(item.entry ?? "index.ts"); - const entryRel = `${dirRel}/${entryFile}`; + + const entryRes = normalizeRelPath(item.entry ?? "index.ts"); + if (entryRes._tag === "Err") return entryRes; + + const entryRel = `${dirRel}/${entryRes.value}`; const mkdirSet = new Set([configRoot.opencodeDir, kindDir, targetDir]); - const writes = item.files.map((file) => { - const rel = normalizeRelPath(file.path); - const dest = path.join(targetDir, ...rel.split("/")); + const writes: InstallPlan["writes"] = []; + + for (const file of item.files) { + const relRes = normalizeRelPath(file.path); + if (relRes._tag === "Err") return relRes; + + const dest = path.join(targetDir, ...relRes.value.split("/")); mkdirSet.add(path.dirname(dest)); - return { path: dest, content: file.content, mode: file.mode }; - }); + writes.push({ path: dest, content: file.content, mode: file.mode }); + } const postinstall = item.postinstall ? { commands: item.postinstall.commands, - cwd: path.resolve( - configRoot.rootDir, - item.postinstall.cwd ?? ".", - ), + cwd: path.resolve(configRoot.rootDir, item.postinstall.cwd ?? "."), } : null; - return { + plans.push({ configRoot, item: { kind: item.kind, @@ -162,112 +374,53 @@ export function planInstalls( dependencies: { // TODO: decide bun add defaults }, - }; - }); -} - -async function ensureDirs(dirs: string[]): Promise { - for (const d of dirs) { - await mkdir(d, { recursive: true }); + }); } -} -async function writeAtomic(dest: string, content: string): Promise { - const dir = path.dirname(dest); - await mkdir(dir, { recursive: true }); - - const base = path.join(dir, `.tmp-ocx-${Date.now()}-`); - const tmp = await mkdtemp(base); - const file = path.join(tmp, "file"); - - await Bun.write(file, content); - await rename(file, dest); - await rm(tmp, { recursive: true, force: true }); + return ok(plans); } -async function stageDir( - plan: InstallPlan, - overwrite: boolean, -): Promise { - const dir = path.dirname(plan.item.targetDir); - await mkdir(dir, { recursive: true }); +export function applyInstallPlansEffect( + plans: InstallPlan[], + opts: { + overwrite: boolean; + allowPostinstall: boolean; + }, +): Effect.Effect { + const root = plans[0]?.configRoot; - const tmp = await mkdtemp(path.join(dir, `.tmp-ocx-${plan.item.name}-`)); + if (plans.length === 0) return Effect.fail(noPlans()); + if (!root) return Effect.fail(missingConfigRoot()); - try { - for (const w of plan.writes) { - const rel = path.relative(plan.item.targetDir, w.path); - if (rel.startsWith("..") || path.isAbsolute(rel)) { - throw new Error(`Refusing to write outside target dir: ${w.path}`); - } + const wrote: string[] = []; - const dest = path.join(tmp, rel); - await mkdir(path.dirname(dest), { recursive: true }); - await Bun.write(dest, w.content); - } + 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 { - 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 { - 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}`); - } - } + }); } export async function applyInstallPlans( @@ -276,40 +429,8 @@ export async function applyInstallPlans( overwrite: boolean; allowPostinstall: boolean; }, -): Promise { - if (plans.length === 0) { - throw new Error("No install plans to apply"); - } - - const root = plans[0]?.configRoot; - if (!root) throw new Error("Missing config root"); - - await ensureDirs([root.opencodeDir]); - - const wrote: string[] = []; - - for (const plan of plans) { - const dir = await stageDir(plan, opts.overwrite); - wrote.push(dir); - } - - await ensureDirs([path.dirname(root.configPath)]); - await updateConfig(root.configPath, plans); - - let ran = false; - if (opts.allowPostinstall) { - for (const plan of plans) { - if (!plan.postinstall) continue; - ran = true; - await runPostinstall(plan); - } - } - - return { - wroteFiles: wrote, - editedConfigPath: root.configPath, - ranPostinstall: ran, - }; +): Promise> { + return runEffect(applyInstallPlansEffect(plans, opts)); } export function defaultHomeDir(): string { diff --git a/packages/core/src/registry/jsonc.ts b/packages/core/src/registry/jsonc.ts index dd17a45..7a702f6 100644 --- a/packages/core/src/registry/jsonc.ts +++ b/packages/core/src/registry/jsonc.ts @@ -1,3 +1,6 @@ +import type { JSONCError } from "./errors"; +import { err, ok, type Result } from "./result"; + type Quote = "\"" | "'"; type State = { @@ -8,6 +11,16 @@ type State = { inBlockComment: boolean; }; +type JSONCResult = Result; + +function jsoncError(message: string): JSONCError { + return { _tag: "JSONCError", message }; +} + +function fail(message: string): JSONCResult { + return err(jsoncError(message)); +} + function createState(): State { return { inString: false, @@ -69,7 +82,7 @@ function findClose( open: number, openChar: "{" | "[", closeChar: "}" | "]", -): number { +): JSONCResult { const st = createState(); let depth = 0; @@ -127,18 +140,18 @@ function findClose( if (ch === openChar) depth += 1; if (ch === closeChar) depth -= 1; - if (depth === 0) return i; + if (depth === 0) return ok(i); } - throw new Error("Unterminated JSONC structure"); + return fail("Unterminated JSONC structure"); } -function parseStringToken(text: string, startIndex: number): { +function parseStringToken(text: string, startIndex: number): JSONCResult<{ value: string; endIndex: number; -} { +}> { const q = text[startIndex] ?? ""; - if (!isQuote(q)) throw new Error("Expected string token"); + if (!isQuote(q)) return fail("Expected string token"); let value = ""; let escaped = false; @@ -158,19 +171,19 @@ function parseStringToken(text: string, startIndex: number): { } if (ch === q) { - return { value, endIndex: i + 1 }; + return ok({ value, endIndex: i + 1 }); } value += ch; } - throw new Error("Unterminated string token"); + return fail("Unterminated string token"); } -function parseIdentifierToken(text: string, startIndex: number): { +function parseIdentifierToken(text: string, startIndex: number): JSONCResult<{ value: string; endIndex: number; -} { +}> { let value = ""; let i = startIndex; @@ -188,17 +201,31 @@ function parseIdentifierToken(text: string, startIndex: number): { i += 1; } - if (value.length === 0) throw new Error("Expected identifier token"); + if (value.length === 0) return fail("Expected identifier token"); - return { value, endIndex: i }; + return ok({ value, endIndex: i }); } -function findValueEnd(text: string, valueStart: number): number { +function findValueEnd(text: string, valueStart: number): JSONCResult { const first = text[valueStart] ?? ""; - if (first === "{") return findClose(text, valueStart, "{", "}") + 1; - if (first === "[") return findClose(text, valueStart, "[", "]") + 1; - if (isQuote(first)) return parseStringToken(text, valueStart).endIndex; + if (first === "{") { + const close = findClose(text, valueStart, "{", "}"); + if (close._tag === "Err") return close; + return ok(close.value + 1); + } + + if (first === "[") { + const close = findClose(text, valueStart, "[", "]"); + if (close._tag === "Err") return close; + return ok(close.value + 1); + } + + if (isQuote(first)) { + const tok = parseStringToken(text, valueStart); + if (tok._tag === "Err") return tok; + return ok(tok.value.endIndex); + } const state = createState(); let depth = 0; @@ -256,17 +283,17 @@ function findValueEnd(text: string, valueStart: number): number { if (ch === "{" || ch === "[") depth += 1; if (ch === "}" || ch === "]") { - if (depth === 0) return i; + if (depth === 0) return ok(i); depth -= 1; continue; } if (depth === 0 && (ch === "," || ch === "}" || ch === "]")) { - return i; + return ok(i); } } - return text.length; + return ok(text.length); } function findTopLevelPropertyValueSpan( @@ -274,34 +301,36 @@ function findTopLevelPropertyValueSpan( rootOpenIndex: number, rootCloseIndex: number, propertyName: string, -): null | { valueStart: number; valueEnd: number } { +): JSONCResult { let i = rootOpenIndex + 1; while (i < rootCloseIndex) { i = skip(text, i); - if (i >= rootCloseIndex) return null; + if (i >= rootCloseIndex) return ok(null); const ch = text[i] ?? ""; - if (ch === "}") return null; + if (ch === "}") return ok(null); - const keyToken = isQuote(ch) - ? parseStringToken(text, i) - : parseIdentifierToken(text, i); + const keyTokenRes = isQuote(ch) ? parseStringToken(text, i) : parseIdentifierToken(text, i); + if (keyTokenRes._tag === "Err") return keyTokenRes; - const key = keyToken.value; - i = skip(text, keyToken.endIndex); + const key = keyTokenRes.value.value; + i = skip(text, keyTokenRes.value.endIndex); if ((text[i] ?? "") !== ":") { - throw new Error("Invalid JSONC object: expected ':' after property key"); + return fail("Invalid JSONC object: expected ':' after property key"); } i = skip(text, i + 1); const valueStart = i; - const valueEnd = findValueEnd(text, valueStart); + const valueEndRes = findValueEnd(text, valueStart); + if (valueEndRes._tag === "Err") return valueEndRes; + + const valueEnd = valueEndRes.value; if (key === propertyName) { - return { valueStart, valueEnd }; + return ok({ valueStart, valueEnd }); } i = valueEnd; @@ -309,7 +338,7 @@ function findTopLevelPropertyValueSpan( if ((text[i] ?? "") === ",") i += 1; } - return null; + return ok(null); } function hasAnyProperties(text: string, rootOpenIndex: number, rootCloseIndex: number): boolean { @@ -382,63 +411,79 @@ function lastSignificantChar(text: string, endIndex: number): string { return last; } -function formatValueForProperty(value: unknown): string { +function formatValueForProperty(value: unknown): JSONCResult { const json = JSON.stringify(value, null, 2); - if (json === undefined) throw new Error("Unable to stringify JSON value"); + if (json === undefined) return fail("Unable to stringify JSON value"); - return json.replaceAll("\n", "\n "); + return ok(json.replaceAll("\n", "\n ")); } export function getTopLevelJsoncPropertyValueText( inputText: string, propertyName: string, -): string | null { +): JSONCResult { const original = inputText.length === 0 ? "{}" : inputText; const first = skip(original, 0); if ((original[first] ?? "") !== "{") { - throw new Error("Expected JSONC root object"); + return fail("Expected JSONC root object"); } const rootOpenIndex = first; - const rootCloseIndex = findClose(original, rootOpenIndex, "{", "}"); + const rootCloseRes = findClose(original, rootOpenIndex, "{", "}"); + if (rootCloseRes._tag === "Err") return rootCloseRes; - const span = findTopLevelPropertyValueSpan( + const rootCloseIndex = rootCloseRes.value; + + const spanRes = findTopLevelPropertyValueSpan( original, rootOpenIndex, rootCloseIndex, propertyName, ); + if (spanRes._tag === "Err") return spanRes; - return span ? original.slice(span.valueStart, span.valueEnd) : null; + const span = spanRes.value; + return ok(span ? original.slice(span.valueStart, span.valueEnd) : null); } export function upsertTopLevelJsoncProperty( inputText: string, propertyName: string, propertyValue: unknown, -): string { +): JSONCResult { const original = inputText.length === 0 ? "{}" : inputText; const first = skip(original, 0); if ((original[first] ?? "") !== "{") { - throw new Error("Expected JSONC root object"); + return fail("Expected JSONC root object"); } const rootOpenIndex = first; - const rootCloseIndex = findClose(original, rootOpenIndex, "{", "}"); + const rootCloseRes = findClose(original, rootOpenIndex, "{", "}"); + if (rootCloseRes._tag === "Err") return rootCloseRes; + + const rootCloseIndex = rootCloseRes.value; - const span = findTopLevelPropertyValueSpan( + const spanRes = findTopLevelPropertyValueSpan( original, rootOpenIndex, rootCloseIndex, propertyName, ); + if (spanRes._tag === "Err") return spanRes; + + const span = spanRes.value; + + const formattedValueRes = formatValueForProperty(propertyValue); + if (formattedValueRes._tag === "Err") return formattedValueRes; - const formattedValue = formatValueForProperty(propertyValue); + const formattedValue = formattedValueRes.value; if (span) { - return `${original.slice(0, span.valueStart)}${formattedValue}${original.slice(span.valueEnd)}`; + return ok( + `${original.slice(0, span.valueStart)}${formattedValue}${original.slice(span.valueEnd)}`, + ); } const hasProps = hasAnyProperties(original, rootOpenIndex, rootCloseIndex); @@ -447,5 +492,5 @@ export function upsertTopLevelJsoncProperty( const comma = lastSig === "{" || lastSig === "," ? "" : ","; const insert = `${comma}\n // TODO: windows support\n ${JSON.stringify(propertyName)}: ${formattedValue}\n`; - return `${original.slice(0, rootCloseIndex)}${insert}${original.slice(rootCloseIndex)}`; + return ok(`${original.slice(0, rootCloseIndex)}${insert}${original.slice(rootCloseIndex)}`); } diff --git a/packages/core/src/registry/registry.ts b/packages/core/src/registry/registry.ts index fc09606..8db60bb 100644 --- a/packages/core/src/registry/registry.ts +++ b/packages/core/src/registry/registry.ts @@ -1,118 +1,156 @@ import path from "node:path"; -import { readFile } from "node:fs/promises"; -import { getEmbeddedRegistryItem } from "./embedded"; +import { Effect } from "effect"; +import { getEmbeddedRegistryItemEffect } from "./embedded"; +import type { FetchRegistryItemError, RegistryItemParseError } from "./errors"; +import { err, ok, runEffect, toEffect, type Result } from "./result"; import type { RegistryItem, RegistryItemV1 } from "./types"; +function parseError(message: string): RegistryItemParseError { + return { _tag: "RegistryItemParseError", message }; +} + +function embeddedReadFailed(name: string, cause: unknown): FetchRegistryItemError { + return { _tag: "EmbeddedReadFailed", name, cause }; +} + +function fetchFailed(url: string, status?: number, cause?: unknown): FetchRegistryItemError { + return { _tag: "FetchFailed", url, status, cause }; +} + +function fileReadFailed(p: string, cause: unknown): FetchRegistryItemError { + return { _tag: "FileReadFailed", path: p, cause }; +} + +function jsonParseFailed(source: string, cause: unknown): FetchRegistryItemError { + return { _tag: "JSONParseFailed", source, cause }; +} + +function missingSpec(): FetchRegistryItemError { + return { _tag: "MissingSpec" }; +} + +function unknownSpec(spec: string): FetchRegistryItemError { + return { _tag: "UnknownSpec", spec }; +} + function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } -function asStringArray(val: unknown, field: string): string[] { - if (val === undefined) return []; +function parseStringArray( + val: unknown, + field: string, +): Result { + if (val === undefined) return ok([]); + if (!Array.isArray(val) || val.some((v) => typeof v !== "string")) { - throw new Error(`Invalid registry item: ${field} must be string[]`); + return err(parseError(`Invalid registry item: ${field} must be string[]`)); } - return val; + + return ok(val); +} + +function parseOptionalString( + val: unknown, + field: string, +): Result { + if (val === undefined) return ok(undefined); + if (typeof val === "string") return ok(val); + return err(parseError(`Invalid registry item: ${field} must be string`)); } -function parseV1(raw: unknown): RegistryItemV1 { - if (!isRecord(raw)) throw new Error("Invalid registry item: expected object"); +function parseV1(raw: unknown): Result { + if (!isRecord(raw)) return err(parseError("Invalid registry item: expected object")); const ver = raw.schemaVersion; if (ver !== 1) { - throw new Error("Invalid registry item: unsupported schemaVersion"); + return err(parseError("Invalid registry item: unsupported schemaVersion")); } const kind = raw.kind; if (kind !== "tool" && kind !== "agent" && kind !== "command" && kind !== "themes") { - throw new Error("Invalid registry item: unsupported kind"); + return err(parseError("Invalid registry item: unsupported kind")); } const name = raw.name; if (typeof name !== "string" || name.trim().length === 0) { - throw new Error("Invalid registry item: name must be a non-empty string"); + return err(parseError("Invalid registry item: name must be a non-empty string")); } - const desc = - raw.description === undefined - ? undefined - : typeof raw.description === "string" - ? raw.description - : (() => { - throw new Error("Invalid registry item: description must be string"); - })(); + const descRes = parseOptionalString(raw.description, "description"); + if (descRes._tag === "Err") return descRes; + + const depsRes = parseStringArray(raw.registryDependencies, "registryDependencies"); + if (depsRes._tag === "Err") return depsRes; const filesRaw = raw.files; if (!Array.isArray(filesRaw)) { - throw new Error("Invalid registry item: files must be an array"); + return err(parseError("Invalid registry item: files must be an array")); } - const files = filesRaw.map((f) => { - if (!isRecord(f)) throw new Error("Invalid registry item: file must be object"); + const files: RegistryItemV1["files"] = []; + + for (const f of filesRaw) { + if (!isRecord(f)) return err(parseError("Invalid registry item: file must be object")); + const p = f.path; const content = f.content; if (typeof p !== "string" || p.length === 0) { - throw new Error("Invalid registry item: file.path must be string"); + return err(parseError("Invalid registry item: file.path must be string")); } if (typeof content !== "string") { - throw new Error("Invalid registry item: file.content must be string"); + return err(parseError("Invalid registry item: file.content must be string")); } const m = f.mode; - let mode: "0644" | "0755" | undefined; - if (m === undefined) { - mode = undefined; - } else if (m === "0644" || m === "0755") { - mode = m; - } else { - throw new Error("Invalid registry item: file.mode must be 0644|0755"); + files.push({ path: p, content }); + continue; } - return mode ? { path: p, content, mode } : { path: p, content }; - }); + if (m !== "0644" && m !== "0755") { + return err(parseError("Invalid registry item: file.mode must be 0644|0755")); + } - const entryRaw = raw.entry; - const entry = - entryRaw === undefined - ? undefined - : typeof entryRaw === "string" - ? entryRaw - : (() => { - throw new Error("Invalid registry item: entry must be string"); - })(); + files.push({ path: p, content, mode: m }); + } + + const entryRes = parseOptionalString(raw.entry, "entry"); + if (entryRes._tag === "Err") return entryRes; const post = raw.postinstall; - const postinstall = - post === undefined - ? undefined - : (() => { - if (!isRecord(post)) { - throw new Error("Invalid registry item: postinstall must be object"); - } - const cmds = post.commands; - if (!Array.isArray(cmds) || cmds.some((c) => typeof c !== "string")) { - throw new Error("Invalid registry item: postinstall.commands must be string[]"); - } - const cwd = post.cwd; - if (cwd !== undefined && typeof cwd !== "string") { - throw new Error("Invalid registry item: postinstall.cwd must be string"); - } - return { commands: cmds, cwd }; - })(); - - return { + let postinstall: RegistryItemV1["postinstall"]; + + if (post === undefined) { + postinstall = undefined; + } else { + if (!isRecord(post)) { + return err(parseError("Invalid registry item: postinstall must be object")); + } + + const cmds = post.commands; + if (!Array.isArray(cmds) || cmds.some((c) => typeof c !== "string")) { + return err(parseError("Invalid registry item: postinstall.commands must be string[]")); + } + + const cwdRes = parseOptionalString(post.cwd, "postinstall.cwd"); + if (cwdRes._tag === "Err") return cwdRes; + + postinstall = { commands: cmds, cwd: cwdRes.value }; + } + + return ok({ schemaVersion: 1, kind, name, - description: desc, - registryDependencies: asStringArray(raw.registryDependencies, "registryDependencies"), + description: descRes.value, + registryDependencies: depsRes.value, files, - entry, + entry: entryRes.value, postinstall, - }; + }); } function isUrl(spec: string): boolean { @@ -123,12 +161,36 @@ function isPath(spec: string): boolean { return spec.startsWith("/") || spec.startsWith("./") || spec.startsWith("../") || spec.endsWith(".json"); } -async function fetchJSON(url: string): Promise { - const res = await fetch(url); - if (!res.ok) { - throw new Error(`Failed to fetch registry item: ${url} (${res.status})`); - } - return await res.json(); +function fetchJSONEffect(url: string): Effect.Effect { + return Effect.tryPromise({ + try: () => fetch(url), + catch: (cause): FetchRegistryItemError => fetchFailed(url, undefined, cause), + }).pipe( + Effect.flatMap((res) => { + if (!res.ok) { + return Effect.fail(fetchFailed(url, res.status)); + } + + return Effect.tryPromise({ + try: () => res.json(), + catch: (cause): FetchRegistryItemError => jsonParseFailed(url, cause), + }); + }), + ); +} + +function readTextEffect(p: string): Effect.Effect { + return Effect.tryPromise({ + try: () => Bun.file(p).text(), + catch: (cause): FetchRegistryItemError => fileReadFailed(p, cause), + }); +} + +function parseJsonEffect(text: string, source: string): Effect.Effect { + return Effect.try({ + try: () => JSON.parse(text) as unknown, + catch: (cause): FetchRegistryItemError => jsonParseFailed(source, cause), + }); } export type FetchRegistryItemResult = { @@ -136,33 +198,51 @@ export type FetchRegistryItemResult = { source: string; }; -export async function fetchRegistryItem( +export function fetchRegistryItemEffect( spec: string, opts: { cwd: string }, -): Promise { +): Effect.Effect { const s = spec.trim(); if (s.length === 0) { - throw new Error("Missing registry item spec"); - } - - const embedded = await getEmbeddedRegistryItem(s); - if (embedded) { - return { item: embedded, source: `embedded:${embedded.kind}/${embedded.name}` }; + return Effect.fail(missingSpec()); } - if (isUrl(s)) { - const raw = await fetchJSON(s); - return { item: parseV1(raw), source: s }; - } - - if (isPath(s)) { - const resolved = path.isAbsolute(s) ? s : path.resolve(opts.cwd, s); - const text = await readFile(resolved, "utf8"); - return { item: parseV1(JSON.parse(text)), source: resolved }; - } - - throw new Error( - `Unknown registry spec: ${s} (try embedded item, URL, or path to .json)`, + return getEmbeddedRegistryItemEffect(s).pipe( + Effect.mapError((e): FetchRegistryItemError => embeddedReadFailed(e.name, e.cause)), + Effect.flatMap((embedded) => { + if (embedded) { + return Effect.succeed({ + item: embedded, + source: `embedded:${embedded.kind}/${embedded.name}`, + }); + } + + if (isUrl(s)) { + return fetchJSONEffect(s).pipe( + Effect.flatMap((raw) => toEffect(parseV1(raw))), + Effect.map((item) => ({ item, source: s })), + ); + } + + if (isPath(s)) { + const resolved = path.isAbsolute(s) ? s : path.resolve(opts.cwd, s); + + return readTextEffect(resolved).pipe( + Effect.flatMap((text) => parseJsonEffect(text, resolved)), + Effect.flatMap((raw) => toEffect(parseV1(raw))), + Effect.map((item) => ({ item, source: resolved })), + ); + } + + return Effect.fail(unknownSpec(s)); + }), ); } + +export async function fetchRegistryItem( + spec: string, + opts: { cwd: string }, +): Promise> { + return runEffect(fetchRegistryItemEffect(spec, opts)); +} diff --git a/packages/core/src/registry/resolve.ts b/packages/core/src/registry/resolve.ts index f8a68f9..aa0dfe1 100644 --- a/packages/core/src/registry/resolve.ts +++ b/packages/core/src/registry/resolve.ts @@ -1,42 +1,64 @@ +import { Effect } from "effect"; +import type { ResolveRegistryTreeError } from "./errors"; +import { runEffect, type Result } from "./result"; +import { fetchRegistryItemEffect } from "./registry"; import type { RegistryItem } from "./types"; -import { fetchRegistryItem } from "./registry"; export type ResolvedItem = { item: RegistryItem; source: string; }; -export async function resolveRegistryTree( +function dependencyCycle(at: string): ResolveRegistryTreeError { + return { _tag: "DependencyCycle", at }; +} + +export function resolveRegistryTreeEffect( specs: string[], opts: { cwd: string }, -): Promise { +): Effect.Effect { const map = new Map(); const visiting = new Set(); const out: ResolvedItem[] = []; - async function visit(spec: string): Promise { - const res = await fetchRegistryItem(spec, opts); - const key = `${res.item.kind}/${res.item.name}`; - - if (map.has(key)) return; - if (visiting.has(key)) { - throw new Error(`Registry dependency cycle detected at ${key}`); - } + const visit = (spec: string): Effect.Effect => + fetchRegistryItemEffect(spec, opts).pipe( + Effect.flatMap((res) => { + const key = `${res.item.kind}/${res.item.name}`; - visiting.add(key); + if (map.has(key)) return Effect.void; + if (visiting.has(key)) return Effect.fail(dependencyCycle(key)); - for (const dep of res.item.registryDependencies ?? []) { - await visit(dep); - } + const deps = res.item.registryDependencies ?? []; - visiting.delete(key); - map.set(key, res); - out.push(res); - } + return Effect.sync(() => { + visiting.add(key); + }).pipe( + Effect.zipRight( + Effect.forEach(deps, (dep) => visit(dep), { concurrency: 1 }).pipe( + Effect.ensuring( + Effect.sync(() => { + visiting.delete(key); + }), + ), + ), + ), + Effect.zipRight( + Effect.sync(() => { + map.set(key, res); + out.push(res); + }), + ), + ); + }), + ); - for (const spec of specs) { - await visit(spec); - } + return Effect.forEach(specs, (spec) => visit(spec), { concurrency: 1 }).pipe(Effect.as(out)); +} - return out; +export async function resolveRegistryTree( + specs: string[], + opts: { cwd: string }, +): Promise> { + return runEffect(resolveRegistryTreeEffect(specs, opts)); } diff --git a/packages/core/src/registry/result.ts b/packages/core/src/registry/result.ts index 29154e7..cdde264 100644 --- a/packages/core/src/registry/result.ts +++ b/packages/core/src/registry/result.ts @@ -1,3 +1,5 @@ +import { Effect } from "effect"; + export type Ok = { _tag: "Ok"; value: T; @@ -47,17 +49,25 @@ export function mapError( return err(fn(res.error)); } -export function trySync(fn: () => T, onError: (cause: unknown) => E): Result { - try { - return ok(fn()); - } catch (cause) { - return err(onError(cause)); - } -} - export async function tryPromise( fn: () => Promise, onError: (cause: unknown) => E, ): Promise> { return fn().then(ok).catch((cause) => err(onError(cause))); } + +export function toEffect(res: Result): Effect.Effect { + if (res._tag === "Ok") return Effect.succeed(res.value); + return Effect.fail(res.error); +} + +export async function runEffect(eff: Effect.Effect): Promise> { + return Effect.runPromise( + eff.pipe( + Effect.match({ + onFailure: (error) => err(error), + onSuccess: (value) => ok(value), + }), + ), + ); +} From cc6c3dcc86235955a0c7a58b41fe3b14d71adb0e Mon Sep 17 00:00:00 2001 From: cau1k Date: Sat, 13 Dec 2025 00:25:10 -0500 Subject: [PATCH 3/3] refac: address registry review feedback --- bun.lock | 2 +- package.json | 2 +- packages/cli/index.ts | 9 +++++---- packages/core/src/registry/config-root.ts | 2 +- packages/core/src/registry/embedded.ts | 2 +- packages/core/src/registry/install.ts | 17 +++++++++++++---- packages/core/src/registry/jsonc.ts | 4 ++-- 7 files changed, 24 insertions(+), 14 deletions(-) diff --git a/bun.lock b/bun.lock index a4075ce..621d055 100644 --- a/bun.lock +++ b/bun.lock @@ -109,7 +109,7 @@ "catalog": { "@opencode-ai/plugin": "latest", "@types/bun": "latest", - "effect": "latest", + "effect": "3.19.11", "handlebars": "4.7.8", "typescript": "5.9.3", }, diff --git a/package.json b/package.json index b5cce20..de76daf 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "@types/bun": "latest", "typescript": "5.9.3", "handlebars": "4.7.8", - "effect": "latest" + "effect": "3.19.11" }, "scripts": { "compile:scripts": "bun build scripts/* --compile --out dist/scripts", diff --git a/packages/cli/index.ts b/packages/cli/index.ts index 53f0811..bd1b8f8 100644 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -9,6 +9,7 @@ import { resolveConfigRoot, resolveRegistryTree, type InstallError, + type InstallPlan, type ResolveRegistryTreeError, } from "@better-slop/core/registry"; @@ -86,12 +87,12 @@ function exitWithError(error: ResolveRegistryTreeError | InstallError): void { process.exitCode = 1; } -function printHooks(plans: Array<{ item: { kind: string; name: string }; postinstall: null | { commands: string[] } }>): void { - const with_hooks = plans.filter((p) => p.postinstall && p.postinstall.commands.length > 0); - if (with_hooks.length === 0) return; +function printHooks(plans: Array>): void { + const withHooks = plans.filter((p) => p.postinstall && p.postinstall.commands.length > 0); + if (withHooks.length === 0) return; console.log("\nPostinstall hooks detected (skipped unless --allow-postinstall):"); - for (const p of with_hooks) { + for (const p of withHooks) { console.log(`- ${p.item.kind}/${p.item.name}`); for (const cmd of p.postinstall?.commands ?? []) { console.log(` - ${cmd}`); diff --git a/packages/core/src/registry/config-root.ts b/packages/core/src/registry/config-root.ts index 3cb3105..f67228a 100644 --- a/packages/core/src/registry/config-root.ts +++ b/packages/core/src/registry/config-root.ts @@ -3,7 +3,7 @@ import path from "node:path"; import { stat } from "node:fs/promises"; import type { ConfigRoot } from "./types"; -async function isDir(p: string): Promise { +function isDir(p: string): Promise { return stat(p).then((s) => s.isDirectory()).catch(() => false); } diff --git a/packages/core/src/registry/embedded.ts b/packages/core/src/registry/embedded.ts index 0f04a6f..85635da 100644 --- a/packages/core/src/registry/embedded.ts +++ b/packages/core/src/registry/embedded.ts @@ -12,7 +12,7 @@ export function listEmbeddedItems(): EmbeddedName[] { } export type EmbeddedReadFailed = { - _tag: "EmbeddedReadFailed"; + readonly _tag: "EmbeddedReadFailed"; name: string; cause: unknown; }; diff --git a/packages/core/src/registry/install.ts b/packages/core/src/registry/install.ts index 34f565d..ffae80d 100644 --- a/packages/core/src/registry/install.ts +++ b/packages/core/src/registry/install.ts @@ -22,7 +22,7 @@ type OCXManagedConfig = { items: Record>; }; -function jsoncError(message: string): JSONCError { +function createJSONCError(message: string): JSONCError { return { _tag: "JSONCError", message }; } @@ -105,6 +105,7 @@ function computeDirRel(configRoot: ConfigRoot, kind: OCXItemKind, name: string): function toMode(mode: "0644" | "0755"): number { if (mode === "0644") return 0o644; + if (mode === "0755") return 0o755; return 0o755; } @@ -228,11 +229,11 @@ function parseExistingOCXManagedConfigEffect(text: string): Effect.Effect JSON.parse(val) as unknown, - catch: (cause) => jsoncError(`Invalid JSON in ocx property: ${stringifyCause(cause)}`), + catch: (cause) => createJSONCError(`Invalid JSON in ocx property: ${stringifyCause(cause)}`), }).pipe( Effect.flatMap((parsed) => { if (!isRecord(parsed)) { - return Effect.fail(jsoncError("Invalid ocx config: expected object")); + return Effect.fail(createJSONCError("Invalid ocx config: expected object")); } const raw = parsed.items; @@ -253,7 +254,15 @@ function parseExistingOCXManagedConfigEffect(text: string): Effect.Effect { - return Effect.promise(() => Bun.file(configPath).text().catch(() => "{}" as const)).pipe( + return Effect.tryPromise({ + try: async () => { + const file = Bun.file(configPath); + const exists = await file.exists(); + if (!exists) return "{}"; + return await file.text(); + }, + catch: (cause): InstallError => ioError("read", cause, configPath), + }).pipe( Effect.flatMap((text) => parseExistingOCXManagedConfigEffect(text).pipe( Effect.flatMap((cfg) => diff --git a/packages/core/src/registry/jsonc.ts b/packages/core/src/registry/jsonc.ts index 7a702f6..9aef1d5 100644 --- a/packages/core/src/registry/jsonc.ts +++ b/packages/core/src/registry/jsonc.ts @@ -13,12 +13,12 @@ type State = { type JSONCResult = Result; -function jsoncError(message: string): JSONCError { +function createJSONCError(message: string): JSONCError { return { _tag: "JSONCError", message }; } function fail(message: string): JSONCResult { - return err(jsoncError(message)); + return err(createJSONCError(message)); } function createState(): State {