Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"@opencode-ai/plugin": "latest",
"@types/bun": "latest",
"typescript": "5.9.3",
"handlebars": "4.7.8"
"handlebars": "4.7.8",
"effect": "3.19.11"
},
"scripts": {
"compile:scripts": "bun build scripts/* --compile --out dist/scripts",
Expand Down
104 changes: 87 additions & 17 deletions packages/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@ import { hideBin } from "yargs/helpers";
import type { ArgumentsCamelCase } from "yargs";
import {
applyInstallPlans,
isErr,
listEmbeddedItems,
planInstalls,
resolveConfigRoot,
resolveRegistryTree,
type InstallError,
type InstallPlan,
type ResolveRegistryTreeError,
} from "@better-slop/core/registry";

type GlobalOpts = {
Expand All @@ -19,16 +23,76 @@ type AddOpts = GlobalOpts & {
"allow-postinstall": boolean;
};

type Plan = ReturnType<typeof planInstalls>[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,
);
if (with_hooks.length === 0) return;
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;
}
}
Comment on lines +31 to +83
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.


function exitWithError(error: ResolveRegistryTreeError | InstallError): void {
console.error(formatError(error));
process.exitCode = 1;
}

function printHooks(plans: Array<Pick<InstallPlan, "item" | "postinstall">>): 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}`);
Expand All @@ -38,13 +102,17 @@ function printHooks(plans: Plan[]): void {

async function runAdd(argv: ArgumentsCamelCase<AddOpts>): Promise<void> {
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})`);
Expand All @@ -62,11 +130,15 @@ async function runAdd(argv: ArgumentsCamelCase<AddOpts>): Promise<void> {
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"}`);
Expand Down Expand Up @@ -144,10 +216,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;
}
});
3 changes: 2 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
}
},
"dependencies": {
"@opencode-ai/plugin": "catalog:"
"@opencode-ai/plugin": "catalog:",
"effect": "catalog:"
},
"devDependencies": {
"@better-slop-internals/tsconfig": "workspace:*",
Expand Down
9 changes: 2 additions & 7 deletions packages/core/src/registry/config-root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,8 @@ import path from "node:path";
import { stat } from "node:fs/promises";
import type { ConfigRoot } from "./types";

async function isDir(p: string): Promise<boolean> {
try {
const s = await stat(p);
return s.isDirectory();
} catch {
return false;
}
function isDir(p: string): Promise<boolean> {
return stat(p).then((s) => s.isDirectory()).catch(() => false);
}

function normalize(p: string): string {
Expand Down
31 changes: 20 additions & 11 deletions packages/core/src/registry/embedded.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Effect } from "effect";
import type { RegistryItemV1 } from "./types";

const TOOLS = {
Expand All @@ -10,18 +11,28 @@ export function listEmbeddedItems(): EmbeddedName[] {
return Object.keys(TOOLS) as EmbeddedName[];
}

export async function getEmbeddedRegistryItem(
export type EmbeddedReadFailed = {
readonly _tag: "EmbeddedReadFailed";
name: string;
cause: unknown;
};

export function getEmbeddedRegistryItemEffect(
name: string,
): Promise<RegistryItemV1 | null> {
): Effect.Effect<RegistryItemV1 | null, EmbeddedReadFailed> {
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,
Expand All @@ -33,8 +44,6 @@ export async function getEmbeddedRegistryItem(
},
],
entry: "index.ts",
};
}

return null;
})),
);
}
32 changes: 32 additions & 0 deletions packages/core/src/registry/errors.ts
Original file line number Diff line number Diff line change
@@ -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;
20 changes: 15 additions & 5 deletions packages/core/src/registry/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Loading