From a5c1d7100196d21dfec77420af9ca70c927e6bf2 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 21 May 2026 00:31:03 +0900 Subject: [PATCH] refactor(packages): extract utils package --- .omo/evidence/task-2-no-coupling.txt | 2 + .omo/evidence/task-2-package-exists.txt | 9 ++ .omo/evidence/task-2-reexports.txt | 12 ++ .omo/evidence/task-2-tests.txt | 1 + .../package-layering-refactor/decisions.md | 10 ++ .../package-layering-refactor/learnings.md | 23 +++ bun.lock | 11 ++ package.json | 6 +- packages/utils/package.json | 22 +++ packages/utils/src/contains-path.ts | 50 +++++++ .../utils/src}/deep-merge.test.ts | 0 packages/utils/src/deep-merge.ts | 53 +++++++ packages/utils/src/extract-semver.ts | 9 ++ .../utils/src}/file-utils.test.ts | 0 packages/utils/src/file-utils.ts | 34 +++++ .../utils/src}/format-duration.test.ts | 0 packages/utils/src/format-duration.ts | 16 +++ .../utils/src}/frontmatter.test.ts | 0 packages/utils/src/frontmatter.ts | 31 ++++ packages/utils/src/index.ts | 12 ++ .../src}/jsonc-parser.memoization.test.ts | 15 +- .../utils/src}/jsonc-parser.test.ts | 17 ++- packages/utils/src/jsonc-parser.ts | 133 ++++++++++++++++++ .../utils/src}/port-utils.test.ts | 0 packages/utils/src/port-utils.ts | 83 +++++++++++ packages/utils/src/record-type-guard.ts | 3 + .../src}/replace-tool-args.audit.test.ts | 2 +- .../utils/src}/replace-tool-args.test.ts | 0 packages/utils/src/replace-tool-args.ts | 16 +++ packages/utils/src/snake-case.ts | 44 ++++++ .../utils/src}/tool-name.test.ts | 0 packages/utils/src/tool-name.ts | 27 ++++ packages/utils/tsconfig.json | 14 ++ src/cli/config-manager/config-context.ts | 6 +- src/cli/doctor/checks/config.ts | 11 +- .../doctor/checks/model-resolution-config.ts | 11 +- src/cli/doctor/checks/team-mode.ts | 11 +- src/cli/doctor/checks/tools-lsp.ts | 6 +- src/features/boulder-state/format-duration.ts | 17 +-- src/plugin-config.ts | 10 +- src/shared/contains-path.ts | 51 +------ src/shared/deep-merge.ts | 54 +------ src/shared/extract-semver.ts | 10 +- src/shared/file-utils.ts | 35 +---- src/shared/frontmatter.ts | 32 +---- src/shared/jsonc-parser.ts | 126 ++--------------- src/shared/port-utils.ts | 90 +----------- src/shared/project-discovery-dirs.ts | 6 +- src/shared/record-type-guard.ts | 4 +- src/shared/replace-tool-args.ts | 17 +-- src/shared/snake-case.ts | 45 +----- src/shared/tool-name.ts | 28 +--- 52 files changed, 717 insertions(+), 508 deletions(-) create mode 100644 .omo/evidence/task-2-no-coupling.txt create mode 100644 .omo/evidence/task-2-package-exists.txt create mode 100644 .omo/evidence/task-2-reexports.txt create mode 100644 .omo/evidence/task-2-tests.txt create mode 100644 .omo/notepads/package-layering-refactor/decisions.md create mode 100644 .omo/notepads/package-layering-refactor/learnings.md create mode 100644 packages/utils/package.json create mode 100644 packages/utils/src/contains-path.ts rename {src/shared => packages/utils/src}/deep-merge.test.ts (100%) create mode 100644 packages/utils/src/deep-merge.ts create mode 100644 packages/utils/src/extract-semver.ts rename {src/shared => packages/utils/src}/file-utils.test.ts (100%) create mode 100644 packages/utils/src/file-utils.ts rename {src/features/boulder-state => packages/utils/src}/format-duration.test.ts (100%) create mode 100644 packages/utils/src/format-duration.ts rename {src/shared => packages/utils/src}/frontmatter.test.ts (100%) create mode 100644 packages/utils/src/frontmatter.ts create mode 100644 packages/utils/src/index.ts rename {src/shared => packages/utils/src}/jsonc-parser.memoization.test.ts (83%) rename {src/shared => packages/utils/src}/jsonc-parser.test.ts (94%) create mode 100644 packages/utils/src/jsonc-parser.ts rename {src/shared => packages/utils/src}/port-utils.test.ts (100%) create mode 100644 packages/utils/src/port-utils.ts create mode 100644 packages/utils/src/record-type-guard.ts rename {src/shared => packages/utils/src}/replace-tool-args.audit.test.ts (97%) rename {src/shared => packages/utils/src}/replace-tool-args.test.ts (100%) create mode 100644 packages/utils/src/replace-tool-args.ts create mode 100644 packages/utils/src/snake-case.ts rename {src/shared => packages/utils/src}/tool-name.test.ts (100%) create mode 100644 packages/utils/src/tool-name.ts create mode 100644 packages/utils/tsconfig.json diff --git a/.omo/evidence/task-2-no-coupling.txt b/.omo/evidence/task-2-no-coupling.txt new file mode 100644 index 000000000..9edddc122 --- /dev/null +++ b/.omo/evidence/task-2-no-coupling.txt @@ -0,0 +1,2 @@ +# Command: grep -rE "@opencode-ai|from [\"']opencode/|Bun\.|from [\"']bun:" --exclude='*.test.ts' --exclude='*.audit.test.ts' packages/utils/src/ +# Result: no matches diff --git a/.omo/evidence/task-2-package-exists.txt b/.omo/evidence/task-2-package-exists.txt new file mode 100644 index 000000000..b8d5d722a --- /dev/null +++ b/.omo/evidence/task-2-package-exists.txt @@ -0,0 +1,9 @@ +12 files moved (write-file-atomically.ts deferred β€” see .omo/notepads/package-layering-refactor/decisions.md) + +total 16 +drwxr-xr-x@ 6 yeongyu staff 192 May 21 00:17 . +drwxr-xr-x@ 19 yeongyu staff 608 May 21 00:12 .. +drwxr-xr-x@ 5 yeongyu staff 160 May 21 00:17 node_modules +-rw-r--r--@ 1 yeongyu staff 487 May 21 00:12 package.json +drwxr-xr-x@ 25 yeongyu staff 800 May 21 00:13 src +-rw-r--r--@ 1 yeongyu staff 312 May 21 00:12 tsconfig.json diff --git a/.omo/evidence/task-2-reexports.txt b/.omo/evidence/task-2-reexports.txt new file mode 100644 index 000000000..90ebb376d --- /dev/null +++ b/.omo/evidence/task-2-reexports.txt @@ -0,0 +1,12 @@ +src/shared/deep-merge.ts: export { deepMerge, isPlainObject } from "@oh-my-opencode/utils" +src/shared/snake-case.ts: export { camelToSnake, objectToCamelCase, objectToSnakeCase, snakeToCamel, transformObjectKeys } from "@oh-my-opencode/utils" +src/shared/record-type-guard.ts: export { isRecord } from "@oh-my-opencode/utils" +src/shared/extract-semver.ts: export { extractSemverFromOutput } from "@oh-my-opencode/utils" +src/shared/frontmatter.ts: export { parseFrontmatter, type FrontmatterResult } from "@oh-my-opencode/utils" +src/shared/file-utils.ts: export { isMarkdownFile, isSymbolicLink, resolveSymlink, resolveSymlinkAsync } from "@oh-my-opencode/utils" +src/shared/contains-path.ts: export { containsPath, isWithinProject } from "@oh-my-opencode/utils" +src/shared/port-utils.ts: export { +src/shared/tool-name.ts: export { transformToolName } from "@oh-my-opencode/utils" +src/shared/replace-tool-args.ts: export { replaceToolArgs } from "@oh-my-opencode/utils" +src/shared/jsonc-parser.ts: export { +src/features/boulder-state/format-duration.ts: export { formatDurationHuman } from "@oh-my-opencode/utils" diff --git a/.omo/evidence/task-2-tests.txt b/.omo/evidence/task-2-tests.txt new file mode 100644 index 000000000..92ca0f2f7 --- /dev/null +++ b/.omo/evidence/task-2-tests.txt @@ -0,0 +1 @@ +Received: "# Debugging\n\nYou are a hypothesis-driven debugger. Two disciplines apply regardless of language, runtime, or whether you have source:\n\n1. **Runtime truth beats code reading.** Every claim about why the bug happens must come from observed state β€” never from a plausible story spun from reading code.\n2. **Leave no trace.** Debugging creates artifacts. Every artifact is journaled and removed before you call the task done.\n\nThe rest of this file is a map. **The knowledge is in `references/`.** This file cannot teach you how to debug β€” it can only tell you which reference will, for your exact situation.\n\n---\n\n# 🚨 READ THE REFERENCES. THIS IS NOT OPTIONAL.\n\n> **This skill is intentionally small.** Ninety percent of what you need to know lives in `references/`. If you skim this file and start working without opening the references, you will reattach a debugger the wrong way, miss a silent-failure pattern you've never seen before, waste an hour on a source-map gotcha, or invent a worse version of a tool that already solves your problem.\n>\n> **Every reference below is mandatory when its scenario applies.** \"I know this language\" is not an exemption. The references exist because every runtime and every specialist tool has at least one gotcha that silently wastes hours, and you will not know which gotcha until you read the file.\n>\n> **The gate rule**: before you run a command from a given reference's domain, you must have read that reference in this session. Re-reading across sessions is cheap. Guessing is expensive.\n\n---\n\n## Runtime Setup β€” MANDATORY READING BEFORE ATTACHING\n\nThe methodology is language-agnostic. The commands to launch, attach, breakpoint, and inspect are not. **Open the matching reference before Phase 0. Not during. Not after.**\n\n| Your runtime is… | Open this before attaching anything | Non-negotiable because… |\n|---|---|---|\n| Python (CPython, pytest, asyncio, Django, FastAPI) | πŸ“– **[references/runtimes/python.md](references/runtimes/python.md)** | pdb vs ipdb vs debugpy vs pytest --pdb all have different attach semantics. Async code needs special breakpoint handling. Wrappers like `poetry run` swallow flags. |\n| Node.js / tsx / ts-node / Bun / Deno (running source) | πŸ“– **[references/runtimes/node.md](references/runtimes/node.md)** | `tsx` + `node inspect` CLI has a **silent source-map failure** β€” breakpoints by line number do not fire. You will not notice unless you read this first. |\n| Rust (cargo, tokio, panics) | πŸ“– **[references/runtimes/rust.md](references/runtimes/rust.md)** | Release builds strip symbols. Tokio tasks need `tokio-console`. The borrow checker makes `dbg!` the faster tool most of the time. |\n| Go (goroutines, dlv, pprof, race) | πŸ“– **[references/runtimes/go.md](references/runtimes/go.md)** | Goroutine leaks and recovered panics are silent by default. `dlv` has a specific port convention. `go test -race` is the first thing to run, not the last. |\n| Native binary / stripped C/C++ / no source | πŸ“– **[references/runtimes/native-binary.md](references/runtimes/native-binary.md)** | The workflow (triage β†’ dynamic β†’ static β†’ scripted repro) is counterintuitive if you've never done it. `strings -n 8` silently drops short interpolations like `${x}` β€” read bytes directly for any extraction that matters. macOS adds SIP / Mach-O / lldb specifics that don't apply on Linux. |\n| **Bundled-app binary** (Bun SEA, Node SEA, Deno compile, pkg, nexe, Electron, Tauri, PyInstaller) | πŸ“– **[references/runtimes/bundled-js-binary.md](references/runtimes/bundled-js-binary.md)** | These look like Mach-O / ELF but their *high-level* source is recoverable with the right per-bundler tool β€” Ghidra is overkill. Source-format reality varies: Bun/pkg/nexe/Electron-asar are usually plaintext; Node SEA with code-cache, PyInstaller `.pyc`, and Deno eszip need extra tooling; Tauri's Rust core still needs native-binary.md. Workflow: identify bundler β†’ locate bundle β†’ extract with the bundler-specific tool β†’ grep. |\n\n...\n\n 7312 pass\n 1 skip\n 2 fail\n 24 snapshots, 16703 expect() calls\nRan 7315 tests across 744 files. [180.07s] diff --git a/.omo/notepads/package-layering-refactor/decisions.md b/.omo/notepads/package-layering-refactor/decisions.md new file mode 100644 index 000000000..37290190a --- /dev/null +++ b/.omo/notepads/package-layering-refactor/decisions.md @@ -0,0 +1,10 @@ +## [2026-05-20T15:12:12Z] Task 2 scope reduction +Dropped `write-file-atomically.ts` from utils extraction. +Reason: depends on omo-specific `tolerant-fsync` chain (classify-path-environment, fsync-skip-tracker, logger). +Decision deferred to a future task. +Net effect: utils package ships 12 files instead of 13. + +## [2026-05-20T15:12:12Z] Task 2 jsonc-parser decoupling +Choice: Option A +Reason: Small call-site surface; keeps full jsonc-parser API together in the new package and avoids split ownership. +Call sites changed: src/plugin-config.ts, src/cli/config-manager/config-context.ts, src/cli/doctor/checks/config.ts, src/cli/doctor/checks/model-resolution-config.ts, src/cli/doctor/checks/team-mode.ts, src/cli/doctor/checks/tools-lsp.ts, src/shared/project-discovery-dirs.ts, packages/utils/src/jsonc-parser.test.ts, packages/utils/src/jsonc-parser.memoization.test.ts diff --git a/.omo/notepads/package-layering-refactor/learnings.md b/.omo/notepads/package-layering-refactor/learnings.md new file mode 100644 index 000000000..b22fffed0 --- /dev/null +++ b/.omo/notepads/package-layering-refactor/learnings.md @@ -0,0 +1,23 @@ + +## [2026-05-20T15:04:52Z] Task 1 baseline +- Total tests: 7315 +- Pass: 7312, Fail: 2, Skip: 1 +- Build exit code: 0 (dist/ size: 13M, 1456 files) +- Typecheck exit code: 0 +- Packages: 15 package.json files under packages/, 3 private (ast-grep-mcp, rules-core, web) +- Anomalies observed: 2 pre-existing test failures in `src/features/opencode-skill-loader/skill-content.test.ts` β€” ambiguous short name resolution returns 2 resolved skills instead of 1 when "debugging" + "playwright" are queried together. This is a REAL baseline failure; do not fix as part of this refactor plan unless explicitly directed. + +## [2026-05-20T15:12:12Z] Task 2 pre-flight +- `src/shared/deep-merge.ts`: pure, zero imports. +- `src/shared/snake-case.ts`: imports `./deep-merge` only (moved together) β€” pure after extraction. +- `src/shared/record-type-guard.ts`: pure, zero imports. +- `src/shared/extract-semver.ts`: pure, zero imports. +- `src/shared/frontmatter.ts`: imports `js-yaml` only. +- `src/shared/file-utils.ts`: imports `fs` only. +- `src/shared/contains-path.ts`: imports `fs` and `path` only. +- `src/shared/port-utils.ts`: imports `node:net` only. +- `src/shared/tool-name.ts`: pure, zero imports. +- `src/shared/replace-tool-args.ts`: pure, zero imports. +- `src/features/boulder-state/format-duration.ts`: pure, zero imports. +- `src/shared/jsonc-parser.ts`: coupled to plugin basenames; decoupled via parameterized `detectPluginConfigFile(dir, options)`. +- `src/shared/write-file-atomically.ts`: depends on omo-specific `./tolerant-fsync`; extraction deferred by scope decision (kept in-place). diff --git a/bun.lock b/bun.lock index 84f2ff966..fc18fb94b 100644 --- a/bun.lock +++ b/bun.lock @@ -26,6 +26,7 @@ "devDependencies": { "@oh-my-opencode/ast-grep-mcp": "workspace:*", "@oh-my-opencode/rules-core": "workspace:*", + "@oh-my-opencode/utils": "workspace:*", "@types/js-yaml": "^4.0.9", "@types/picomatch": "^4.0.3", "@typescript/native-preview": "7.0.0-dev.20260518.1", @@ -70,6 +71,14 @@ "picomatch": "^4.0.4", }, }, + "packages/utils": { + "name": "@oh-my-opencode/utils", + "version": "0.1.0", + "dependencies": { + "js-yaml": "^4.1.1", + "jsonc-parser": "^3.3.1", + }, + }, }, "trustedDependencies": [ "@ast-grep/cli", @@ -146,6 +155,8 @@ "@oh-my-opencode/rules-core": ["@oh-my-opencode/rules-core@workspace:packages/rules-core"], + "@oh-my-opencode/utils": ["@oh-my-opencode/utils@workspace:packages/utils"], + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.15.4", "", { "dependencies": { "@opencode-ai/sdk": "1.15.4", "effect": "4.0.0-beta.65", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.2.11", "@opentui/keymap": ">=0.2.11", "@opentui/solid": ">=0.2.11" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-5KAhUnks8GNlqRIax+3cs/ZT2UK74/MNdl4w846ysYdivb38fIm+X9R69ljQtRKyQY7rtga4JUQuARJMSExQqQ=="], "@opencode-ai/sdk": ["@opencode-ai/sdk@1.15.4", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-55SBChNouj2XY9C4thO0w7SGJS3jD2DRBxzcrDpc5szgmJJ2t2Wu38uZh+TQMBLHA8YrTPDqgfnc7o5tx2qRPw=="], diff --git a/package.json b/package.json index 4167476d9..357e8be7b 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "type": "module", "workspaces": [ "packages/rules-core", - "packages/ast-grep-mcp" + "packages/ast-grep-mcp", + "packages/utils" ], "bin": { "oh-my-opencode": "bin/oh-my-opencode.js", @@ -42,7 +43,7 @@ "prepublishOnly": "bun run clean && bun run build:lsp-tools-mcp && bun run build", "test:model-capabilities": "bun test src/shared/model-capability-aliases.test.ts src/shared/model-capability-guardrails.test.ts src/shared/model-capabilities.test.ts src/cli/doctor/checks/model-resolution.test.ts --bail", "typecheck": "tsgo --noEmit && bun run typecheck:packages", - "typecheck:packages": "tsgo --noEmit -p packages/rules-core/tsconfig.json && tsgo --noEmit -p packages/ast-grep-mcp/tsconfig.json", + "typecheck:packages": "tsgo --noEmit -p packages/rules-core/tsconfig.json && tsgo --noEmit -p packages/ast-grep-mcp/tsconfig.json && tsgo --noEmit -p packages/utils/tsconfig.json", "typecheck:script": "tsgo --noEmit -p script/tsconfig.json", "test": "bun test", "build:ast-grep-mcp": "bun run --cwd packages/ast-grep-mcp build" @@ -88,6 +89,7 @@ "devDependencies": { "@oh-my-opencode/ast-grep-mcp": "workspace:*", "@oh-my-opencode/rules-core": "workspace:*", + "@oh-my-opencode/utils": "workspace:*", "@typescript/native-preview": "7.0.0-dev.20260518.1", "@types/js-yaml": "^4.0.9", "@types/picomatch": "^4.0.3", diff --git a/packages/utils/package.json b/packages/utils/package.json new file mode 100644 index 000000000..52b9cda0c --- /dev/null +++ b/packages/utils/package.json @@ -0,0 +1,22 @@ +{ + "name": "@oh-my-opencode/utils", + "version": "0.1.0", + "type": "module", + "private": true, + "description": "Pure TypeScript shared utilities for oh-my-opencode.", + "exports": { + ".": { + "types": "./index.d.ts", + "import": "./src/index.ts" + } + }, + "types": "./index.d.ts", + "scripts": { + "typecheck": "tsgo --noEmit -p tsconfig.json", + "test": "bun test src/*.test.ts" + }, + "dependencies": { + "js-yaml": "^4.1.1", + "jsonc-parser": "^3.3.1" + } +} diff --git a/packages/utils/src/contains-path.ts b/packages/utils/src/contains-path.ts new file mode 100644 index 000000000..f51d1f70d --- /dev/null +++ b/packages/utils/src/contains-path.ts @@ -0,0 +1,50 @@ +import { existsSync, realpathSync } from "fs" +import { basename, dirname, isAbsolute, join, normalize, relative, resolve } from "path" + +function findNearestExistingAncestor(resolvedPath: string): string { + let candidatePath = resolvedPath + + while (!existsSync(candidatePath)) { + const parentPath = dirname(candidatePath) + + if (parentPath === candidatePath) { + return candidatePath + } + + candidatePath = parentPath + } + + return candidatePath +} + +function toCanonicalPath(pathToNormalize: string): string { + const resolvedPath = resolve(pathToNormalize) + + if (existsSync(resolvedPath)) { + try { + return normalize(realpathSync.native(resolvedPath)) + } catch { + return normalize(resolvedPath) + } + } + + const nearestExistingAncestor = findNearestExistingAncestor(resolvedPath) + const canonicalAncestor = existsSync(nearestExistingAncestor) + ? realpathSync.native(nearestExistingAncestor) + : nearestExistingAncestor + const relativePathFromAncestor = relative(nearestExistingAncestor, resolvedPath) + + return normalize(join(canonicalAncestor, relativePathFromAncestor || basename(resolvedPath))) +} + +export function containsPath(rootPath: string, candidatePath: string): boolean { + const canonicalRootPath = toCanonicalPath(rootPath) + const canonicalCandidatePath = toCanonicalPath(candidatePath) + const relativePath = relative(canonicalRootPath, canonicalCandidatePath) + + return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath)) +} + +export function isWithinProject(candidatePath: string, projectRoot: string): boolean { + return containsPath(projectRoot, candidatePath) +} diff --git a/src/shared/deep-merge.test.ts b/packages/utils/src/deep-merge.test.ts similarity index 100% rename from src/shared/deep-merge.test.ts rename to packages/utils/src/deep-merge.test.ts diff --git a/packages/utils/src/deep-merge.ts b/packages/utils/src/deep-merge.ts new file mode 100644 index 000000000..92567824c --- /dev/null +++ b/packages/utils/src/deep-merge.ts @@ -0,0 +1,53 @@ +const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]); +const MAX_DEPTH = 50; + +export function isPlainObject(value: unknown): value is Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.prototype.toString.call(value) === "[object Object]" + ); +} + +/** + * Deep merges two objects, with override values taking precedence. + * - Objects are recursively merged + * - Arrays are replaced (not concatenated) + * - undefined values in override do not overwrite base values + * + * @example + * deepMerge({ a: 1, b: { c: 2, d: 3 } }, { b: { c: 10 }, e: 5 }) + * // => { a: 1, b: { c: 10, d: 3 }, e: 5 } + */ +export function deepMerge>(base: T, override: Partial, depth?: number): T; +export function deepMerge>(base: T | undefined, override: T | undefined, depth?: number): T | undefined; +export function deepMerge>( + base: T | undefined, + override: T | undefined, + depth = 0 +): T | undefined { + if (!base && !override) return undefined; + if (!base) return override; + if (!override) return base; + if (depth > MAX_DEPTH) return override ?? base; + + const result = { ...base } as Record; + + for (const key of Object.keys(override)) { + if (DANGEROUS_KEYS.has(key)) continue; + + const baseValue = base[key]; + const overrideValue = override[key]; + + if (overrideValue === undefined) continue; + + if (isPlainObject(baseValue) && isPlainObject(overrideValue)) { + result[key] = deepMerge(baseValue, overrideValue, depth + 1); + } else { + result[key] = overrideValue; + } + } + + return result as T; +} diff --git a/packages/utils/src/extract-semver.ts b/packages/utils/src/extract-semver.ts new file mode 100644 index 000000000..37e55d052 --- /dev/null +++ b/packages/utils/src/extract-semver.ts @@ -0,0 +1,9 @@ +export function extractSemverFromOutput(output: string): string | null { + const trimmed = output.trim() + if (!trimmed) return null + // The negative lookbehind `(? boolean }): boolean { + return !entry.name.startsWith(".") && entry.name.endsWith(".md") && entry.isFile() +} + +export function isSymbolicLink(filePath: string): boolean { + try { + return lstatSync(filePath, { throwIfNoEntry: false })?.isSymbolicLink() ?? false + } catch { + return false + } +} + +export function resolveSymlink(filePath: string): string { + try { + return normalizeDarwinRealpath(realpathSync(filePath)) + } catch { + return filePath + } +} + +export async function resolveSymlinkAsync(filePath: string): Promise { + try { + return normalizeDarwinRealpath(await fs.realpath(filePath)) + } catch { + return filePath + } +} diff --git a/src/features/boulder-state/format-duration.test.ts b/packages/utils/src/format-duration.test.ts similarity index 100% rename from src/features/boulder-state/format-duration.test.ts rename to packages/utils/src/format-duration.test.ts diff --git a/packages/utils/src/format-duration.ts b/packages/utils/src/format-duration.ts new file mode 100644 index 000000000..8065ddbd6 --- /dev/null +++ b/packages/utils/src/format-duration.ts @@ -0,0 +1,16 @@ +export function formatDurationHuman(milliseconds: number): string { + const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000)) + const hours = Math.floor(totalSeconds / 3600) + const minutes = Math.floor((totalSeconds % 3600) / 60) + const seconds = totalSeconds % 60 + + if (hours > 0) { + return `${hours}h ${minutes}m ${seconds}s` + } + + if (minutes > 0) { + return `${minutes}m ${seconds}s` + } + + return `${seconds}s` +} diff --git a/src/shared/frontmatter.test.ts b/packages/utils/src/frontmatter.test.ts similarity index 100% rename from src/shared/frontmatter.test.ts rename to packages/utils/src/frontmatter.test.ts diff --git a/packages/utils/src/frontmatter.ts b/packages/utils/src/frontmatter.ts new file mode 100644 index 000000000..db16420da --- /dev/null +++ b/packages/utils/src/frontmatter.ts @@ -0,0 +1,31 @@ +import yaml from "js-yaml" + +export interface FrontmatterResult> { + data: T + body: string + hadFrontmatter: boolean + parseError: boolean +} + +export function parseFrontmatter>( + content: string +): FrontmatterResult { + const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n?---\r?\n([\s\S]*)$/ + const match = content.match(frontmatterRegex) + + if (!match) { + return { data: {} as T, body: content, hadFrontmatter: false, parseError: false } + } + + const yamlContent = match[1] + const body = match[2] + + try { + // Use JSON_SCHEMA for security - prevents code execution via YAML tags + const parsed = yaml.load(yamlContent, { schema: yaml.JSON_SCHEMA }) + const data = (parsed ?? {}) as T + return { data, body, hadFrontmatter: true, parseError: false } + } catch { + return { data: {} as T, body, hadFrontmatter: true, parseError: true } + } +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts new file mode 100644 index 000000000..ab3a9b2bf --- /dev/null +++ b/packages/utils/src/index.ts @@ -0,0 +1,12 @@ +export * from "./deep-merge" +export * from "./snake-case" +export * from "./record-type-guard" +export * from "./extract-semver" +export * from "./frontmatter" +export * from "./file-utils" +export * from "./contains-path" +export * from "./port-utils" +export * from "./tool-name" +export * from "./replace-tool-args" +export * from "./format-duration" +export * from "./jsonc-parser" diff --git a/src/shared/jsonc-parser.memoization.test.ts b/packages/utils/src/jsonc-parser.memoization.test.ts similarity index 83% rename from src/shared/jsonc-parser.memoization.test.ts rename to packages/utils/src/jsonc-parser.memoization.test.ts index c4cd1f5b8..378b8f981 100644 --- a/src/shared/jsonc-parser.memoization.test.ts +++ b/packages/utils/src/jsonc-parser.memoization.test.ts @@ -2,6 +2,11 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import * as fs from "node:fs" import { join } from "node:path" +const pluginConfigDetectionOptions = { + basenames: ["oh-my-openagent"], + legacyBasenames: ["oh-my-opencode"], +} as const + describe("detectPluginConfigFile memoization", () => { const testDir = join(__dirname, ".test-detect-plugin-memoization") @@ -15,14 +20,13 @@ describe("detectPluginConfigFile memoization", () => { return String(filePath).endsWith("oh-my-openagent.jsonc") }) const readdirSync = spyOn(fs, "readdirSync").mockImplementation(() => []) - spyOn(fs, "readFileSync").mockImplementation(() => "") const parserModule = await import(`./jsonc-parser?memoization=${Date.now()}-${Math.random()}`) // when - const firstResult = parserModule.detectPluginConfigFile(testDir) + const firstResult = parserModule.detectPluginConfigFile(testDir, pluginConfigDetectionOptions) const callsAfterFirstResult = existsSync.mock.calls.length - const secondResult = parserModule.detectPluginConfigFile(testDir) + const secondResult = parserModule.detectPluginConfigFile(testDir, pluginConfigDetectionOptions) // then expect(firstResult).toEqual(secondResult) @@ -36,16 +40,15 @@ describe("detectPluginConfigFile memoization", () => { return String(filePath).endsWith("oh-my-openagent.jsonc") }) const readdirSync = spyOn(fs, "readdirSync").mockImplementation(() => []) - spyOn(fs, "readFileSync").mockImplementation(() => "") const parserModule = await import(`./jsonc-parser?memoization=${Date.now()}-${Math.random()}`) - parserModule.detectPluginConfigFile(testDir) + parserModule.detectPluginConfigFile(testDir, pluginConfigDetectionOptions) parserModule.clearPluginConfigFileDetectionCache() const callsAfterClear = existsSync.mock.calls.length // when - parserModule.detectPluginConfigFile(testDir) + parserModule.detectPluginConfigFile(testDir, pluginConfigDetectionOptions) // then expect(existsSync.mock.calls.length).toBeGreaterThan(callsAfterClear) diff --git a/src/shared/jsonc-parser.test.ts b/packages/utils/src/jsonc-parser.test.ts similarity index 94% rename from src/shared/jsonc-parser.test.ts rename to packages/utils/src/jsonc-parser.test.ts index c06e36353..74ec03e8e 100644 --- a/src/shared/jsonc-parser.test.ts +++ b/packages/utils/src/jsonc-parser.test.ts @@ -3,6 +3,11 @@ import { clearPluginConfigFileDetectionCache, detectConfigFile, detectPluginConf import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs" import { join } from "node:path" +const pluginConfigDetectionOptions = { + basenames: ["oh-my-openagent"], + legacyBasenames: ["oh-my-opencode"], +} as const + describe("parseJsonc", () => { test("parses plain JSON", () => { // given @@ -345,7 +350,7 @@ describe("detectPluginConfigFile", () => { writeFileSync(join(testDir, "oh-my-opencode.jsonc"), "{}") // when - const result = detectPluginConfigFile(testDir) + const result = detectPluginConfigFile(testDir, pluginConfigDetectionOptions) // then expect(result.format).toBe("jsonc") @@ -361,7 +366,7 @@ describe("detectPluginConfigFile", () => { writeFileSync(join(testDir, "oh-my-opencode.jsonc"), "{}") // when - const result = detectPluginConfigFile(testDir) + const result = detectPluginConfigFile(testDir, pluginConfigDetectionOptions) // then expect(result.format).toBe("jsonc") @@ -378,7 +383,7 @@ describe("detectPluginConfigFile", () => { writeFileSync(join(testDir, "oh-my-opencode.json"), "{}") // when - const result = detectPluginConfigFile(testDir) + const result = detectPluginConfigFile(testDir, pluginConfigDetectionOptions) // then expect(result.format).toBe("json") @@ -394,7 +399,7 @@ describe("detectPluginConfigFile", () => { if (!existsSync(emptyDir)) mkdirSync(emptyDir, { recursive: true }) // when - const result = detectPluginConfigFile(emptyDir) + const result = detectPluginConfigFile(emptyDir, pluginConfigDetectionOptions) // then expect(result.format).toBe("none") @@ -410,7 +415,7 @@ describe("detectPluginConfigFile", () => { writeFileSync(join(testDir, "oh-my-openagent.jsonc"), "{}") // when - const result = detectPluginConfigFile(testDir) + const result = detectPluginConfigFile(testDir, pluginConfigDetectionOptions) // then expect(result.format).toBe("jsonc") @@ -426,7 +431,7 @@ describe("detectPluginConfigFile", () => { writeFileSync(join(testDir, "oh-my-openagent.jsonc"), "{}") // when - const result = detectPluginConfigFile(testDir) + const result = detectPluginConfigFile(testDir, pluginConfigDetectionOptions) // then expect(result.format).toBe("jsonc") diff --git a/packages/utils/src/jsonc-parser.ts b/packages/utils/src/jsonc-parser.ts new file mode 100644 index 000000000..bb22c4b25 --- /dev/null +++ b/packages/utils/src/jsonc-parser.ts @@ -0,0 +1,133 @@ +import { existsSync, readFileSync } from "node:fs" +import { join } from "node:path" +import { parse, ParseError, printParseErrorCode } from "jsonc-parser" + +export interface JsoncParseResult { + data: T | null + errors: Array<{ message: string; offset: number; length: number }> +} + +type DetectPluginConfigResult = { + format: "json" | "jsonc" | "none" + path: string + legacyPath?: string +} + +export interface DetectPluginConfigFileOptions { + readonly basenames: readonly [string, ...string[]] + readonly legacyBasenames?: readonly string[] +} + +const pluginConfigFileDetectionCache = new Map() + +function getPluginConfigCacheKey(dir: string, options: DetectPluginConfigFileOptions): string { + const basenames = [...options.basenames].join(",") + const legacyBasenames = options.legacyBasenames ? [...options.legacyBasenames].join(",") : "" + return `${dir}::${basenames}::${legacyBasenames}` +} + +function stripBom(content: string): string { + return content.charCodeAt(0) === 0xfeff ? content.slice(1) : content +} + +export function parseJsonc(content: string): T { + // Strip UTF-8 BOM if present (Windows UTF-8 with BOM files) + content = content.replace(/^\uFEFF/, "") + + const errors: ParseError[] = [] + const result = parse(stripBom(content), errors, { + allowTrailingComma: true, + disallowComments: false, + }) as T + + if (errors.length > 0) { + const errorMessages = errors + .map((e) => `${printParseErrorCode(e.error)} at offset ${e.offset}`) + .join(", ") + throw new SyntaxError(`JSONC parse error: ${errorMessages}`) + } + + return result +} + +export function parseJsoncSafe(content: string): JsoncParseResult { + const errors: ParseError[] = [] + const data = parse(stripBom(content), errors, { + allowTrailingComma: true, + disallowComments: false, + }) as T | null + + return { + data: errors.length > 0 ? null : data, + errors: errors.map((e) => ({ + message: printParseErrorCode(e.error), + offset: e.offset, + length: e.length, + })), + } +} + +export function readJsoncFile(filePath: string): T | null { + try { + const content = readFileSync(filePath, "utf-8") + return parseJsonc(content) + } catch { + return null + } +} + +export function detectConfigFile(basePath: string): { + format: "json" | "jsonc" | "none" + path: string +} { + const jsoncPath = `${basePath}.jsonc` + const jsonPath = `${basePath}.json` + + if (existsSync(jsoncPath)) { + return { format: "jsonc", path: jsoncPath } + } + if (existsSync(jsonPath)) { + return { format: "json", path: jsonPath } + } + return { format: "none", path: jsonPath } +} + +export function clearPluginConfigFileDetectionCache(): void { + pluginConfigFileDetectionCache.clear() +} + +export function detectPluginConfigFile( + dir: string, + options: DetectPluginConfigFileOptions, +): DetectPluginConfigResult { + const cacheKey = getPluginConfigCacheKey(dir, options) + const cachedResult = pluginConfigFileDetectionCache.get(cacheKey) + + if (cachedResult !== undefined) { + return cachedResult + } + + const canonicalBasename = options.basenames[0] + const canonicalResult = detectConfigFile(join(dir, canonicalBasename)) + const legacyResults = (options.legacyBasenames ?? []).map((legacyBasename) => + detectConfigFile(join(dir, legacyBasename)), + ) + const firstExistingLegacyResult = legacyResults.find((result) => result.format !== "none") + + let detectionResult: DetectPluginConfigResult + + if (canonicalResult.format !== "none") { + detectionResult = { + ...canonicalResult, + legacyPath: firstExistingLegacyResult?.path, + } + } else if (firstExistingLegacyResult) { + detectionResult = firstExistingLegacyResult + } else { + detectionResult = { format: "none", path: join(dir, `${canonicalBasename}.json`) } + } + + pluginConfigFileDetectionCache.set(cacheKey, detectionResult) + + return detectionResult +} diff --git a/src/shared/port-utils.test.ts b/packages/utils/src/port-utils.test.ts similarity index 100% rename from src/shared/port-utils.test.ts rename to packages/utils/src/port-utils.test.ts diff --git a/packages/utils/src/port-utils.ts b/packages/utils/src/port-utils.ts new file mode 100644 index 000000000..94a908a97 --- /dev/null +++ b/packages/utils/src/port-utils.ts @@ -0,0 +1,83 @@ +import { createServer } from "node:net" + +const DEFAULT_SERVER_PORT = 4096 +const MAX_PORT_ATTEMPTS = 20 +const PORT_CHECK_TIMEOUT_MS = 2000 + +export async function isPortAvailable(port: number, hostname: string = "127.0.0.1"): Promise { + return new Promise((resolve) => { + const server = createServer() + let timeoutId: ReturnType | undefined + let resolved = false + + const finish = (isAvailable: boolean): void => { + if (resolved) { + return + } + resolved = true + if (timeoutId) { + clearTimeout(timeoutId) + } + server.removeAllListeners("error") + server.removeAllListeners("listening") + resolve(isAvailable) + } + + const closeThenFinish = (isAvailable: boolean): void => { + try { + server.close(() => finish(isAvailable)) + } catch { + finish(isAvailable) + } + } + + timeoutId = setTimeout(() => { + closeThenFinish(false) + }, PORT_CHECK_TIMEOUT_MS) + + server.once("error", () => { + finish(false) + }) + server.once("listening", () => { + closeThenFinish(true) + }) + + try { + server.listen(port, hostname) + } catch { + finish(false) + } + }) +} + +export async function findAvailablePort( + startPort: number = DEFAULT_SERVER_PORT, + hostname: string = "127.0.0.1" +): Promise { + for (let attempt = 0; attempt < MAX_PORT_ATTEMPTS; attempt++) { + const port = startPort + attempt + if (await isPortAvailable(port, hostname)) { + return port + } + } + throw new Error(`No available port found in range ${startPort}-${startPort + MAX_PORT_ATTEMPTS - 1}`) +} + +export interface AutoPortResult { + port: number + wasAutoSelected: boolean +} + +export async function getAvailableServerPort( + preferredPort: number = DEFAULT_SERVER_PORT, + hostname: string = "127.0.0.1" +): Promise { + if (await isPortAvailable(preferredPort, hostname)) { + return { port: preferredPort, wasAutoSelected: false } + } + + const port = await findAvailablePort(preferredPort + 1, hostname) + return { port, wasAutoSelected: true } +} + +export { DEFAULT_SERVER_PORT } diff --git a/packages/utils/src/record-type-guard.ts b/packages/utils/src/record-type-guard.ts new file mode 100644 index 000000000..a901f1a65 --- /dev/null +++ b/packages/utils/src/record-type-guard.ts @@ -0,0 +1,3 @@ +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} diff --git a/src/shared/replace-tool-args.audit.test.ts b/packages/utils/src/replace-tool-args.audit.test.ts similarity index 97% rename from src/shared/replace-tool-args.audit.test.ts rename to packages/utils/src/replace-tool-args.audit.test.ts index 1c85ee6d9..e2bd770d1 100644 --- a/src/shared/replace-tool-args.audit.test.ts +++ b/packages/utils/src/replace-tool-args.audit.test.ts @@ -21,7 +21,7 @@ async function collectTsFiles(dir: string): Promise { return results } -const HELPER_FILE = "shared/replace-tool-args.ts" +const HELPER_FILE = "src/replace-tool-args.ts" // Matches direct mutations like `output.args.foo =` or `toolOutput.args.foo =` // but excludes comparisons (===, !==, ==) diff --git a/src/shared/replace-tool-args.test.ts b/packages/utils/src/replace-tool-args.test.ts similarity index 100% rename from src/shared/replace-tool-args.test.ts rename to packages/utils/src/replace-tool-args.test.ts diff --git a/packages/utils/src/replace-tool-args.ts b/packages/utils/src/replace-tool-args.ts new file mode 100644 index 000000000..0a0566ad3 --- /dev/null +++ b/packages/utils/src/replace-tool-args.ts @@ -0,0 +1,16 @@ +/** + * Safely replace tool arguments without mutating frozen objects. + * + * opencode >=1.14 may freeze `output.args` via Immer before plugin hooks run. + * Direct property assignment (`output.args.key = value`) or `Object.assign(output.args, patch)` + * throws `TypeError: Attempted to assign to readonly property` on a frozen object. + * + * This helper replaces `output.args` with a shallow clone containing the patch, + * which works regardless of whether the original args object is frozen. + */ +export function replaceToolArgs( + output: { args: Record }, + patch: Record, +): void { + output.args = { ...output.args, ...patch } +} diff --git a/packages/utils/src/snake-case.ts b/packages/utils/src/snake-case.ts new file mode 100644 index 000000000..8b9ec5a13 --- /dev/null +++ b/packages/utils/src/snake-case.ts @@ -0,0 +1,44 @@ +import { isPlainObject } from "./deep-merge" + +export function camelToSnake(str: string): string { + return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`) +} + +export function snakeToCamel(str: string): string { + return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()) +} + +export function transformObjectKeys( + obj: Record, + transformer: (key: string) => string, + deep: boolean = true +): Record { + const result: Record = {} + for (const [key, value] of Object.entries(obj)) { + const transformedKey = transformer(key) + if (deep && isPlainObject(value)) { + result[transformedKey] = transformObjectKeys(value, transformer, true) + } else if (deep && Array.isArray(value)) { + result[transformedKey] = value.map((item) => + isPlainObject(item) ? transformObjectKeys(item, transformer, true) : item + ) + } else { + result[transformedKey] = value + } + } + return result +} + +export function objectToSnakeCase( + obj: Record, + deep: boolean = true +): Record { + return transformObjectKeys(obj, camelToSnake, deep) +} + +export function objectToCamelCase( + obj: Record, + deep: boolean = true +): Record { + return transformObjectKeys(obj, snakeToCamel, deep) +} diff --git a/src/shared/tool-name.test.ts b/packages/utils/src/tool-name.test.ts similarity index 100% rename from src/shared/tool-name.test.ts rename to packages/utils/src/tool-name.test.ts diff --git a/packages/utils/src/tool-name.ts b/packages/utils/src/tool-name.ts new file mode 100644 index 000000000..b93b07021 --- /dev/null +++ b/packages/utils/src/tool-name.ts @@ -0,0 +1,27 @@ +const SPECIAL_TOOL_MAPPINGS: Record = { + webfetch: "WebFetch", + websearch: "WebSearch", + todoread: "TodoRead", + todowrite: "TodoWrite", +} + +function toPascalCase(str: string): string { + return str + .split(/[-_\s]+/) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join("") +} + +export function transformToolName(toolName: string): string { + const trimmed = toolName.trim() + const lower = trimmed.toLowerCase() + if (lower in SPECIAL_TOOL_MAPPINGS) { + return SPECIAL_TOOL_MAPPINGS[lower] + } + + if (trimmed.includes("-") || trimmed.includes("_")) { + return toPascalCase(trimmed) + } + + return trimmed.charAt(0).toUpperCase() + trimmed.slice(1) +} diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json new file mode 100644 index 000000000..7a69d1222 --- /dev/null +++ b/packages/utils/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "lib": ["ESNext"], + "types": ["bun-types"] + }, + "include": ["src/**/*"] +} diff --git a/src/cli/config-manager/config-context.ts b/src/cli/config-manager/config-context.ts index c3ae17529..4c6de2a35 100644 --- a/src/cli/config-manager/config-context.ts +++ b/src/cli/config-manager/config-context.ts @@ -1,4 +1,5 @@ import { getOpenCodeConfigPaths, detectPluginConfigFile } from "../../shared" +import { CONFIG_BASENAME, LEGACY_CONFIG_BASENAME } from "../../shared/plugin-identity" import type { OpenCodeBinaryType, OpenCodeConfigPaths, @@ -43,7 +44,10 @@ export function getConfigJsonc(): string { export function getOmoConfigPath(): string { const configDir = getConfigContext().paths.configDir - const detected = detectPluginConfigFile(configDir) + const detected = detectPluginConfigFile(configDir, { + basenames: [CONFIG_BASENAME], + legacyBasenames: [LEGACY_CONFIG_BASENAME], + }) if (detected.format !== "none") return detected.path return getConfigContext().paths.omoConfig } diff --git a/src/cli/doctor/checks/config.ts b/src/cli/doctor/checks/config.ts index 10f3f3bc5..da592ea1d 100644 --- a/src/cli/doctor/checks/config.ts +++ b/src/cli/doctor/checks/config.ts @@ -3,6 +3,7 @@ import { join } from "node:path" import { OhMyOpenCodeConfigSchema } from "../../../config" import { detectPluginConfigFile, getOpenCodeConfigDir, parseJsonc } from "../../../shared" +import { CONFIG_BASENAME, LEGACY_CONFIG_BASENAME } from "../../../shared/plugin-identity" import { CHECK_IDS, CHECK_NAMES, PACKAGE_NAME } from "../constants" import type { CheckResult, DoctorIssue } from "../types" import { loadAvailableModelsFromCache } from "./model-resolution-cache" @@ -20,11 +21,17 @@ interface ConfigValidationResult { } function findConfigPath(): string | null { - const projectConfig = detectPluginConfigFile(PROJECT_CONFIG_DIR) + const projectConfig = detectPluginConfigFile(PROJECT_CONFIG_DIR, { + basenames: [CONFIG_BASENAME], + legacyBasenames: [LEGACY_CONFIG_BASENAME], + }) if (projectConfig.format !== "none") return projectConfig.path const userConfigDir = getOpenCodeConfigDir({ binary: "opencode" }) - const userConfig = detectPluginConfigFile(userConfigDir) + const userConfig = detectPluginConfigFile(userConfigDir, { + basenames: [CONFIG_BASENAME], + legacyBasenames: [LEGACY_CONFIG_BASENAME], + }) if (userConfig.format !== "none") return userConfig.path return null diff --git a/src/cli/doctor/checks/model-resolution-config.ts b/src/cli/doctor/checks/model-resolution-config.ts index 20c5c3f97..2d3fd85f4 100644 --- a/src/cli/doctor/checks/model-resolution-config.ts +++ b/src/cli/doctor/checks/model-resolution-config.ts @@ -1,12 +1,16 @@ import { readFileSync } from "node:fs" import { join } from "node:path" import { detectPluginConfigFile, getOpenCodeConfigDir, parseJsonc } from "../../../shared" +import { CONFIG_BASENAME, LEGACY_CONFIG_BASENAME } from "../../../shared/plugin-identity" import type { OmoConfig } from "./model-resolution-types" const PROJECT_CONFIG_DIR = join(process.cwd(), ".opencode") export function loadOmoConfig(): OmoConfig | null { - const projectDetected = detectPluginConfigFile(PROJECT_CONFIG_DIR) + const projectDetected = detectPluginConfigFile(PROJECT_CONFIG_DIR, { + basenames: [CONFIG_BASENAME], + legacyBasenames: [LEGACY_CONFIG_BASENAME], + }) if (projectDetected.format !== "none") { try { const content = readFileSync(projectDetected.path, "utf-8") @@ -17,7 +21,10 @@ export function loadOmoConfig(): OmoConfig | null { } const userConfigDir = getOpenCodeConfigDir({ binary: "opencode" }) - const userDetected = detectPluginConfigFile(userConfigDir) + const userDetected = detectPluginConfigFile(userConfigDir, { + basenames: [CONFIG_BASENAME], + legacyBasenames: [LEGACY_CONFIG_BASENAME], + }) if (userDetected.format !== "none") { try { const content = readFileSync(userDetected.path, "utf-8") diff --git a/src/cli/doctor/checks/team-mode.ts b/src/cli/doctor/checks/team-mode.ts index 3da6e15be..56490a9c6 100644 --- a/src/cli/doctor/checks/team-mode.ts +++ b/src/cli/doctor/checks/team-mode.ts @@ -6,6 +6,7 @@ import type { CheckResult } from "../types" import { readFileSync, promises as fs } from "node:fs" import path from "node:path" import { detectPluginConfigFile, getOpenCodeConfigDir, parseJsonc } from "../../../shared" +import { CONFIG_BASENAME, LEGACY_CONFIG_BASENAME } from "../../../shared/plugin-identity" export async function checkTeamMode(): Promise { const config = loadTeamModeConfig() @@ -33,8 +34,14 @@ export async function checkTeamMode(): Promise { } function loadTeamModeConfig() { - const projectConfig = detectPluginConfigFile(path.join(process.cwd(), ".opencode")) - const userConfig = detectPluginConfigFile(getOpenCodeConfigDir({ binary: "opencode" })) + const projectConfig = detectPluginConfigFile(path.join(process.cwd(), ".opencode"), { + basenames: [CONFIG_BASENAME], + legacyBasenames: [LEGACY_CONFIG_BASENAME], + }) + const userConfig = detectPluginConfigFile(getOpenCodeConfigDir({ binary: "opencode" }), { + basenames: [CONFIG_BASENAME], + legacyBasenames: [LEGACY_CONFIG_BASENAME], + }) const configPath = projectConfig.format !== "none" ? projectConfig.path : userConfig.path if (!configPath) return { team_mode: undefined } try { diff --git a/src/cli/doctor/checks/tools-lsp.ts b/src/cli/doctor/checks/tools-lsp.ts index b80020e13..b7161428b 100644 --- a/src/cli/doctor/checks/tools-lsp.ts +++ b/src/cli/doctor/checks/tools-lsp.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs" import { join } from "node:path" import { createLspMcpConfig } from "../../../mcp/lsp" import { detectPluginConfigFile, getOpenCodeConfigDir, parseJsonc } from "../../../shared" +import { CONFIG_BASENAME, LEGACY_CONFIG_BASENAME } from "../../../shared/plugin-identity" type OmoConfigForDoctor = { disabled_mcps?: string[] @@ -13,7 +14,10 @@ type InstalledLspServersOptions = { } function readOmoConfig(configDirectory: string): OmoConfigForDoctor | null { - const detected = detectPluginConfigFile(configDirectory) + const detected = detectPluginConfigFile(configDirectory, { + basenames: [CONFIG_BASENAME], + legacyBasenames: [LEGACY_CONFIG_BASENAME], + }) if (detected.format === "none") { return null } diff --git a/src/features/boulder-state/format-duration.ts b/src/features/boulder-state/format-duration.ts index 8065ddbd6..7e3fd8077 100644 --- a/src/features/boulder-state/format-duration.ts +++ b/src/features/boulder-state/format-duration.ts @@ -1,16 +1 @@ -export function formatDurationHuman(milliseconds: number): string { - const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000)) - const hours = Math.floor(totalSeconds / 3600) - const minutes = Math.floor((totalSeconds % 3600) / 60) - const seconds = totalSeconds % 60 - - if (hours > 0) { - return `${hours}h ${minutes}m ${seconds}s` - } - - if (minutes > 0) { - return `${minutes}m ${seconds}s` - } - - return `${seconds}s` -} +export { formatDurationHuman } from "@oh-my-opencode/utils" diff --git a/src/plugin-config.ts b/src/plugin-config.ts index f671ab327..29ba707af 100644 --- a/src/plugin-config.ts +++ b/src/plugin-config.ts @@ -283,7 +283,10 @@ export function loadPluginConfig( ): OhMyOpenCodeConfig { const userConfigDirs = [...getOpenCodeConfigDirs({ binary: "opencode" })].reverse() const userConfigLayers = userConfigDirs.map((configDir) => { - const detected = detectPluginConfigFile(configDir) + const detected = detectPluginConfigFile(configDir, { + basenames: [CONFIG_BASENAME], + legacyBasenames: [LEGACY_CONFIG_BASENAME], + }) if (detected.legacyPath) { log("Canonical plugin config detected alongside legacy config. Remove the legacy file to avoid confusion.", { @@ -318,7 +321,10 @@ export function loadPluginConfig( const canonicalAncestorPathsNearestFirst = ancestorConfigPathsNearestFirst.map( (ancestorPath) => { const opencodeDir = path.dirname(ancestorPath) - const ancestorDetected = detectPluginConfigFile(opencodeDir) + const ancestorDetected = detectPluginConfigFile(opencodeDir, { + basenames: [CONFIG_BASENAME], + legacyBasenames: [LEGACY_CONFIG_BASENAME], + }) if (ancestorDetected.legacyPath) { log("Canonical plugin config detected alongside legacy config. Remove the legacy file to avoid confusion.", { canonicalPath: ancestorDetected.path, diff --git a/src/shared/contains-path.ts b/src/shared/contains-path.ts index f51d1f70d..a1d157257 100644 --- a/src/shared/contains-path.ts +++ b/src/shared/contains-path.ts @@ -1,50 +1 @@ -import { existsSync, realpathSync } from "fs" -import { basename, dirname, isAbsolute, join, normalize, relative, resolve } from "path" - -function findNearestExistingAncestor(resolvedPath: string): string { - let candidatePath = resolvedPath - - while (!existsSync(candidatePath)) { - const parentPath = dirname(candidatePath) - - if (parentPath === candidatePath) { - return candidatePath - } - - candidatePath = parentPath - } - - return candidatePath -} - -function toCanonicalPath(pathToNormalize: string): string { - const resolvedPath = resolve(pathToNormalize) - - if (existsSync(resolvedPath)) { - try { - return normalize(realpathSync.native(resolvedPath)) - } catch { - return normalize(resolvedPath) - } - } - - const nearestExistingAncestor = findNearestExistingAncestor(resolvedPath) - const canonicalAncestor = existsSync(nearestExistingAncestor) - ? realpathSync.native(nearestExistingAncestor) - : nearestExistingAncestor - const relativePathFromAncestor = relative(nearestExistingAncestor, resolvedPath) - - return normalize(join(canonicalAncestor, relativePathFromAncestor || basename(resolvedPath))) -} - -export function containsPath(rootPath: string, candidatePath: string): boolean { - const canonicalRootPath = toCanonicalPath(rootPath) - const canonicalCandidatePath = toCanonicalPath(candidatePath) - const relativePath = relative(canonicalRootPath, canonicalCandidatePath) - - return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath)) -} - -export function isWithinProject(candidatePath: string, projectRoot: string): boolean { - return containsPath(projectRoot, candidatePath) -} +export { containsPath, isWithinProject } from "@oh-my-opencode/utils" diff --git a/src/shared/deep-merge.ts b/src/shared/deep-merge.ts index 92567824c..a3b3d33f1 100644 --- a/src/shared/deep-merge.ts +++ b/src/shared/deep-merge.ts @@ -1,53 +1 @@ -const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]); -const MAX_DEPTH = 50; - -export function isPlainObject(value: unknown): value is Record { - return ( - typeof value === "object" && - value !== null && - !Array.isArray(value) && - Object.prototype.toString.call(value) === "[object Object]" - ); -} - -/** - * Deep merges two objects, with override values taking precedence. - * - Objects are recursively merged - * - Arrays are replaced (not concatenated) - * - undefined values in override do not overwrite base values - * - * @example - * deepMerge({ a: 1, b: { c: 2, d: 3 } }, { b: { c: 10 }, e: 5 }) - * // => { a: 1, b: { c: 10, d: 3 }, e: 5 } - */ -export function deepMerge>(base: T, override: Partial, depth?: number): T; -export function deepMerge>(base: T | undefined, override: T | undefined, depth?: number): T | undefined; -export function deepMerge>( - base: T | undefined, - override: T | undefined, - depth = 0 -): T | undefined { - if (!base && !override) return undefined; - if (!base) return override; - if (!override) return base; - if (depth > MAX_DEPTH) return override ?? base; - - const result = { ...base } as Record; - - for (const key of Object.keys(override)) { - if (DANGEROUS_KEYS.has(key)) continue; - - const baseValue = base[key]; - const overrideValue = override[key]; - - if (overrideValue === undefined) continue; - - if (isPlainObject(baseValue) && isPlainObject(overrideValue)) { - result[key] = deepMerge(baseValue, overrideValue, depth + 1); - } else { - result[key] = overrideValue; - } - } - - return result as T; -} +export { deepMerge, isPlainObject } from "@oh-my-opencode/utils" diff --git a/src/shared/extract-semver.ts b/src/shared/extract-semver.ts index 37e55d052..41a07c98d 100644 --- a/src/shared/extract-semver.ts +++ b/src/shared/extract-semver.ts @@ -1,9 +1 @@ -export function extractSemverFromOutput(output: string): string | null { - const trimmed = output.trim() - if (!trimmed) return null - // The negative lookbehind `(? boolean }): boolean { - return !entry.name.startsWith(".") && entry.name.endsWith(".md") && entry.isFile() -} - -export function isSymbolicLink(filePath: string): boolean { - try { - return lstatSync(filePath, { throwIfNoEntry: false })?.isSymbolicLink() ?? false - } catch { - return false - } -} - -export function resolveSymlink(filePath: string): string { - try { - return normalizeDarwinRealpath(realpathSync(filePath)) - } catch { - return filePath - } -} - -export async function resolveSymlinkAsync(filePath: string): Promise { - try { - return normalizeDarwinRealpath(await fs.realpath(filePath)) - } catch { - return filePath - } -} +export { isMarkdownFile, isSymbolicLink, resolveSymlink, resolveSymlinkAsync } from "@oh-my-opencode/utils" diff --git a/src/shared/frontmatter.ts b/src/shared/frontmatter.ts index db16420da..4bacc7bd7 100644 --- a/src/shared/frontmatter.ts +++ b/src/shared/frontmatter.ts @@ -1,31 +1 @@ -import yaml from "js-yaml" - -export interface FrontmatterResult> { - data: T - body: string - hadFrontmatter: boolean - parseError: boolean -} - -export function parseFrontmatter>( - content: string -): FrontmatterResult { - const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n?---\r?\n([\s\S]*)$/ - const match = content.match(frontmatterRegex) - - if (!match) { - return { data: {} as T, body: content, hadFrontmatter: false, parseError: false } - } - - const yamlContent = match[1] - const body = match[2] - - try { - // Use JSON_SCHEMA for security - prevents code execution via YAML tags - const parsed = yaml.load(yamlContent, { schema: yaml.JSON_SCHEMA }) - const data = (parsed ?? {}) as T - return { data, body, hadFrontmatter: true, parseError: false } - } catch { - return { data: {} as T, body, hadFrontmatter: true, parseError: true } - } -} +export { parseFrontmatter, type FrontmatterResult } from "@oh-my-opencode/utils" diff --git a/src/shared/jsonc-parser.ts b/src/shared/jsonc-parser.ts index bb7148983..4e5061743 100644 --- a/src/shared/jsonc-parser.ts +++ b/src/shared/jsonc-parser.ts @@ -1,116 +1,10 @@ -import { existsSync, readFileSync } from "node:fs" -import { join } from "node:path" -import { parse, ParseError, printParseErrorCode } from "jsonc-parser" - -import { CONFIG_BASENAME, LEGACY_CONFIG_BASENAME } from "./plugin-identity" - -export interface JsoncParseResult { - data: T | null - errors: Array<{ message: string; offset: number; length: number }> -} - -type DetectPluginConfigResult = { - format: "json" | "jsonc" | "none" - path: string - legacyPath?: string -} - -const pluginConfigFileDetectionCache = new Map() - -function stripBom(content: string): string { - return content.charCodeAt(0) === 0xfeff ? content.slice(1) : content -} - -export function parseJsonc(content: string): T { - // Strip UTF-8 BOM if present (Windows UTF-8 with BOM files) - content = content.replace(/^\uFEFF/, "") - - const errors: ParseError[] = [] - const result = parse(stripBom(content), errors, { - allowTrailingComma: true, - disallowComments: false, - }) as T - - if (errors.length > 0) { - const errorMessages = errors - .map((e) => `${printParseErrorCode(e.error)} at offset ${e.offset}`) - .join(", ") - throw new SyntaxError(`JSONC parse error: ${errorMessages}`) - } - - return result -} - -export function parseJsoncSafe(content: string): JsoncParseResult { - const errors: ParseError[] = [] - const data = parse(stripBom(content), errors, { - allowTrailingComma: true, - disallowComments: false, - }) as T | null - - return { - data: errors.length > 0 ? null : data, - errors: errors.map((e) => ({ - message: printParseErrorCode(e.error), - offset: e.offset, - length: e.length, - })), - } -} - -export function readJsoncFile(filePath: string): T | null { - try { - const content = readFileSync(filePath, "utf-8") - return parseJsonc(content) - } catch { - return null - } -} - -export function detectConfigFile(basePath: string): { - format: "json" | "jsonc" | "none" - path: string -} { - const jsoncPath = `${basePath}.jsonc` - const jsonPath = `${basePath}.json` - - if (existsSync(jsoncPath)) { - return { format: "jsonc", path: jsoncPath } - } - if (existsSync(jsonPath)) { - return { format: "json", path: jsonPath } - } - return { format: "none", path: jsonPath } -} - -export function clearPluginConfigFileDetectionCache(): void { - pluginConfigFileDetectionCache.clear() -} - -export function detectPluginConfigFile(dir: string): DetectPluginConfigResult { - const cachedResult = pluginConfigFileDetectionCache.get(dir) - - if (cachedResult !== undefined) { - return cachedResult - } - - const canonicalResult = detectConfigFile(join(dir, CONFIG_BASENAME)) - const legacyResult = detectConfigFile(join(dir, LEGACY_CONFIG_BASENAME)) - - let detectionResult: DetectPluginConfigResult - - if (canonicalResult.format !== "none") { - detectionResult = { - ...canonicalResult, - legacyPath: legacyResult.format !== "none" ? legacyResult.path : undefined, - } - } else if (legacyResult.format !== "none") { - detectionResult = legacyResult - } else { - detectionResult = { format: "none", path: join(dir, `${CONFIG_BASENAME}.json`) } - } - - pluginConfigFileDetectionCache.set(dir, detectionResult) - - return detectionResult -} +export { + clearPluginConfigFileDetectionCache, + detectConfigFile, + detectPluginConfigFile, + parseJsonc, + parseJsoncSafe, + readJsoncFile, + type DetectPluginConfigFileOptions, + type JsoncParseResult, +} from "@oh-my-opencode/utils" diff --git a/src/shared/port-utils.ts b/src/shared/port-utils.ts index 94a908a97..c2fc99fc1 100644 --- a/src/shared/port-utils.ts +++ b/src/shared/port-utils.ts @@ -1,83 +1,7 @@ -import { createServer } from "node:net" - -const DEFAULT_SERVER_PORT = 4096 -const MAX_PORT_ATTEMPTS = 20 -const PORT_CHECK_TIMEOUT_MS = 2000 - -export async function isPortAvailable(port: number, hostname: string = "127.0.0.1"): Promise { - return new Promise((resolve) => { - const server = createServer() - let timeoutId: ReturnType | undefined - let resolved = false - - const finish = (isAvailable: boolean): void => { - if (resolved) { - return - } - resolved = true - if (timeoutId) { - clearTimeout(timeoutId) - } - server.removeAllListeners("error") - server.removeAllListeners("listening") - resolve(isAvailable) - } - - const closeThenFinish = (isAvailable: boolean): void => { - try { - server.close(() => finish(isAvailable)) - } catch { - finish(isAvailable) - } - } - - timeoutId = setTimeout(() => { - closeThenFinish(false) - }, PORT_CHECK_TIMEOUT_MS) - - server.once("error", () => { - finish(false) - }) - server.once("listening", () => { - closeThenFinish(true) - }) - - try { - server.listen(port, hostname) - } catch { - finish(false) - } - }) -} - -export async function findAvailablePort( - startPort: number = DEFAULT_SERVER_PORT, - hostname: string = "127.0.0.1" -): Promise { - for (let attempt = 0; attempt < MAX_PORT_ATTEMPTS; attempt++) { - const port = startPort + attempt - if (await isPortAvailable(port, hostname)) { - return port - } - } - throw new Error(`No available port found in range ${startPort}-${startPort + MAX_PORT_ATTEMPTS - 1}`) -} - -export interface AutoPortResult { - port: number - wasAutoSelected: boolean -} - -export async function getAvailableServerPort( - preferredPort: number = DEFAULT_SERVER_PORT, - hostname: string = "127.0.0.1" -): Promise { - if (await isPortAvailable(preferredPort, hostname)) { - return { port: preferredPort, wasAutoSelected: false } - } - - const port = await findAvailablePort(preferredPort + 1, hostname) - return { port, wasAutoSelected: true } -} - -export { DEFAULT_SERVER_PORT } +export { + DEFAULT_SERVER_PORT, + findAvailablePort, + getAvailableServerPort, + isPortAvailable, + type AutoPortResult, +} from "@oh-my-opencode/utils" diff --git a/src/shared/project-discovery-dirs.ts b/src/shared/project-discovery-dirs.ts index ee53f5486..cbe49a771 100644 --- a/src/shared/project-discovery-dirs.ts +++ b/src/shared/project-discovery-dirs.ts @@ -3,6 +3,7 @@ import { existsSync, realpathSync } from "node:fs" import { dirname, join, resolve } from "node:path" import { detectPluginConfigFile } from "./jsonc-parser" +import { CONFIG_BASENAME, LEGACY_CONFIG_BASENAME } from "./plugin-identity" const worktreePathCache = new Map() @@ -129,7 +130,10 @@ export function findProjectOpencodePluginConfigFiles( while (true) { const opencodeDirectory = join(currentDirectory, ".opencode") if (existsSync(opencodeDirectory)) { - const detected = detectPluginConfigFile(opencodeDirectory) + const detected = detectPluginConfigFile(opencodeDirectory, { + basenames: [CONFIG_BASENAME], + legacyBasenames: [LEGACY_CONFIG_BASENAME], + }) if (detected.format !== "none" && !seen.has(detected.path)) { seen.add(detected.path) paths.push(detected.path) diff --git a/src/shared/record-type-guard.ts b/src/shared/record-type-guard.ts index a901f1a65..f85c5894f 100644 --- a/src/shared/record-type-guard.ts +++ b/src/shared/record-type-guard.ts @@ -1,3 +1 @@ -export function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null -} +export { isRecord } from "@oh-my-opencode/utils" diff --git a/src/shared/replace-tool-args.ts b/src/shared/replace-tool-args.ts index 0a0566ad3..1815d96d0 100644 --- a/src/shared/replace-tool-args.ts +++ b/src/shared/replace-tool-args.ts @@ -1,16 +1 @@ -/** - * Safely replace tool arguments without mutating frozen objects. - * - * opencode >=1.14 may freeze `output.args` via Immer before plugin hooks run. - * Direct property assignment (`output.args.key = value`) or `Object.assign(output.args, patch)` - * throws `TypeError: Attempted to assign to readonly property` on a frozen object. - * - * This helper replaces `output.args` with a shallow clone containing the patch, - * which works regardless of whether the original args object is frozen. - */ -export function replaceToolArgs( - output: { args: Record }, - patch: Record, -): void { - output.args = { ...output.args, ...patch } -} +export { replaceToolArgs } from "@oh-my-opencode/utils" diff --git a/src/shared/snake-case.ts b/src/shared/snake-case.ts index 8b9ec5a13..856e97e75 100644 --- a/src/shared/snake-case.ts +++ b/src/shared/snake-case.ts @@ -1,44 +1 @@ -import { isPlainObject } from "./deep-merge" - -export function camelToSnake(str: string): string { - return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`) -} - -export function snakeToCamel(str: string): string { - return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()) -} - -export function transformObjectKeys( - obj: Record, - transformer: (key: string) => string, - deep: boolean = true -): Record { - const result: Record = {} - for (const [key, value] of Object.entries(obj)) { - const transformedKey = transformer(key) - if (deep && isPlainObject(value)) { - result[transformedKey] = transformObjectKeys(value, transformer, true) - } else if (deep && Array.isArray(value)) { - result[transformedKey] = value.map((item) => - isPlainObject(item) ? transformObjectKeys(item, transformer, true) : item - ) - } else { - result[transformedKey] = value - } - } - return result -} - -export function objectToSnakeCase( - obj: Record, - deep: boolean = true -): Record { - return transformObjectKeys(obj, camelToSnake, deep) -} - -export function objectToCamelCase( - obj: Record, - deep: boolean = true -): Record { - return transformObjectKeys(obj, snakeToCamel, deep) -} +export { camelToSnake, objectToCamelCase, objectToSnakeCase, snakeToCamel, transformObjectKeys } from "@oh-my-opencode/utils" diff --git a/src/shared/tool-name.ts b/src/shared/tool-name.ts index b93b07021..49a438641 100644 --- a/src/shared/tool-name.ts +++ b/src/shared/tool-name.ts @@ -1,27 +1 @@ -const SPECIAL_TOOL_MAPPINGS: Record = { - webfetch: "WebFetch", - websearch: "WebSearch", - todoread: "TodoRead", - todowrite: "TodoWrite", -} - -function toPascalCase(str: string): string { - return str - .split(/[-_\s]+/) - .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) - .join("") -} - -export function transformToolName(toolName: string): string { - const trimmed = toolName.trim() - const lower = trimmed.toLowerCase() - if (lower in SPECIAL_TOOL_MAPPINGS) { - return SPECIAL_TOOL_MAPPINGS[lower] - } - - if (trimmed.includes("-") || trimmed.includes("_")) { - return toPascalCase(trimmed) - } - - return trimmed.charAt(0).toUpperCase() + trimmed.slice(1) -} +export { transformToolName } from "@oh-my-opencode/utils"