refactor(packages): extract utils package

This commit is contained in:
YeonGyu-Kim
2026-05-21 00:31:03 +09:00
parent c4fe747e15
commit a5c1d71001
52 changed files with 717 additions and 508 deletions
+1 -50
View File
@@ -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"
-336
View File
@@ -1,336 +0,0 @@
import { describe, expect, test } from "bun:test"
import { deepMerge, isPlainObject } from "./deep-merge"
type AnyObject = Record<string, unknown>
describe("isPlainObject", () => {
test("returns false for null", () => {
// given
const value = null
// when
const result = isPlainObject(value)
// then
expect(result).toBe(false)
})
test("returns false for undefined", () => {
// given
const value = undefined
// when
const result = isPlainObject(value)
// then
expect(result).toBe(false)
})
test("returns false for string", () => {
// given
const value = "hello"
// when
const result = isPlainObject(value)
// then
expect(result).toBe(false)
})
test("returns false for number", () => {
// given
const value = 42
// when
const result = isPlainObject(value)
// then
expect(result).toBe(false)
})
test("returns false for boolean", () => {
// given
const value = true
// when
const result = isPlainObject(value)
// then
expect(result).toBe(false)
})
test("returns false for array", () => {
// given
const value = [1, 2, 3]
// when
const result = isPlainObject(value)
// then
expect(result).toBe(false)
})
test("returns false for Date", () => {
// given
const value = new Date()
// when
const result = isPlainObject(value)
// then
expect(result).toBe(false)
})
test("returns false for RegExp", () => {
// given
const value = /test/
// when
const result = isPlainObject(value)
// then
expect(result).toBe(false)
})
test("returns true for plain object", () => {
// given
const value = { a: 1 }
// when
const result = isPlainObject(value)
// then
expect(result).toBe(true)
})
test("returns true for empty object", () => {
// given
const value = {}
// when
const result = isPlainObject(value)
// then
expect(result).toBe(true)
})
test("returns true for nested object", () => {
// given
const value = { a: { b: 1 } }
// when
const result = isPlainObject(value)
// then
expect(result).toBe(true)
})
})
describe("deepMerge", () => {
describe("basic merging", () => {
test("merges two simple objects", () => {
// given
const base: AnyObject = { a: 1 }
const override: AnyObject = { b: 2 }
// when
const result = deepMerge(base, override)
// then
expect(result).toEqual({ a: 1, b: 2 })
})
test("override value takes precedence", () => {
// given
const base = { a: 1 }
const override = { a: 2 }
// when
const result = deepMerge(base, override)
// then
expect(result).toEqual({ a: 2 })
})
test("deeply merges nested objects", () => {
// given
const base: AnyObject = { a: { b: 1, c: 2 } }
const override: AnyObject = { a: { b: 10 } }
// when
const result = deepMerge(base, override)
// then
expect(result).toEqual({ a: { b: 10, c: 2 } })
})
test("handles multiple levels of nesting", () => {
// given
const base: AnyObject = { a: { b: { c: { d: 1 } } } }
const override: AnyObject = { a: { b: { c: { e: 2 } } } }
// when
const result = deepMerge(base, override)
// then
expect(result).toEqual({ a: { b: { c: { d: 1, e: 2 } } } })
})
})
describe("edge cases", () => {
test("returns undefined when both are undefined", () => {
// given
const base = undefined
const override = undefined
// when
const result = deepMerge<AnyObject>(base, override)
// then
expect(result).toBeUndefined()
})
test("returns override when base is undefined", () => {
// given
const base = undefined
const override = { a: 1 }
// when
const result = deepMerge<AnyObject>(base, override)
// then
expect(result).toEqual({ a: 1 })
})
test("returns base when override is undefined", () => {
// given
const base = { a: 1 }
const override = undefined
// when
const result = deepMerge<AnyObject>(base, override)
// then
expect(result).toEqual({ a: 1 })
})
test("preserves base value when override value is undefined", () => {
// given
const base = { a: 1, b: 2 }
const override = { a: undefined, b: 3 }
// when
const result = deepMerge(base, override)
// then
expect(result).toEqual({ a: 1, b: 3 })
})
test("does not mutate base object", () => {
// given
const base = { a: 1, b: { c: 2 } }
const override = { b: { c: 10 } }
const originalBase = JSON.parse(JSON.stringify(base))
// when
deepMerge(base, override)
// then
expect(base).toEqual(originalBase)
})
})
describe("array handling", () => {
test("replaces arrays instead of merging them", () => {
// given
const base = { arr: [1, 2] }
const override = { arr: [3, 4, 5] }
// when
const result = deepMerge(base, override)
// then
expect(result).toEqual({ arr: [3, 4, 5] })
})
test("replaces nested arrays", () => {
// given
const base = { a: { arr: [1, 2, 3] } }
const override = { a: { arr: [4] } }
// when
const result = deepMerge(base, override)
// then
expect(result).toEqual({ a: { arr: [4] } })
})
})
describe("prototype pollution protection", () => {
test("ignores __proto__ key", () => {
// given
const base: AnyObject = { a: 1 }
const override: AnyObject = JSON.parse('{"__proto__": {"polluted": true}, "b": 2}')
// when
const result = deepMerge(base, override)
// then
expect(result).toEqual({ a: 1, b: 2 })
expect(({} as AnyObject).polluted).toBeUndefined()
})
test("ignores constructor key", () => {
// given
const base: AnyObject = { a: 1 }
const override: AnyObject = { constructor: { polluted: true }, b: 2 }
// when
const result = deepMerge(base, override)
// then
expect(result!.b).toBe(2)
expect(result!["constructor"]).not.toEqual({ polluted: true })
})
test("ignores prototype key", () => {
// given
const base: AnyObject = { a: 1 }
const override: AnyObject = { prototype: { polluted: true }, b: 2 }
// when
const result = deepMerge(base, override)
// then
expect(result!.b).toBe(2)
expect(result!.prototype).toBeUndefined()
})
})
describe("depth limit", () => {
test("returns override when depth exceeds MAX_DEPTH", () => {
// given
const createDeepObject = (depth: number, leaf: AnyObject): AnyObject => {
if (depth === 0) return leaf
return { nested: createDeepObject(depth - 1, leaf) }
}
// Use different keys to distinguish base vs override
const base = createDeepObject(55, { baseKey: "base" })
const override = createDeepObject(55, { overrideKey: "override" })
// when
const result = deepMerge(base, override)
// then
// Navigate to depth 55 (leaf level, beyond MAX_DEPTH of 50)
let current: AnyObject = result as AnyObject
for (let i = 0; i < 55; i++) {
current = current.nested as AnyObject
}
// At depth 55, only override's key should exist because
// override replaced base entirely at depth 51+ (beyond MAX_DEPTH)
expect(current.overrideKey).toBe("override")
expect(current.baseKey).toBeUndefined()
})
})
})
+1 -53
View File
@@ -1,53 +1 @@
const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
const MAX_DEPTH = 50;
export function isPlainObject(value: unknown): value is Record<string, unknown> {
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<T extends Record<string, unknown>>(base: T, override: Partial<T>, depth?: number): T;
export function deepMerge<T extends Record<string, unknown>>(base: T | undefined, override: T | undefined, depth?: number): T | undefined;
export function deepMerge<T extends Record<string, unknown>>(
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<string, unknown>;
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"
+1 -9
View File
@@ -1,9 +1 @@
export function extractSemverFromOutput(output: string): string | null {
const trimmed = output.trim()
if (!trimmed) return null
// The negative lookbehind `(?<![\d:])` prevents matching the milliseconds segment of timestamps
// like `00:24:25.202` that the Electron-based OpenCode binary leaks into stdout.
const semverPattern = /(?<![\d:])v?(\d+\.\d+\.\d+(?:[-+][\w.]+)*)/
const match = trimmed.match(semverPattern)
return match?.[1] ?? null
}
export { extractSemverFromOutput } from "@oh-my-opencode/utils"
-103
View File
@@ -1,103 +0,0 @@
import { describe, it, expect, beforeAll, afterAll } from "bun:test"
import { mkdirSync, writeFileSync, symlinkSync, rmSync } from "fs"
import { join } from "path"
import { tmpdir } from "os"
import { resolveSymlink, resolveSymlinkAsync, isSymbolicLink } from "./file-utils"
const testDir = join(tmpdir(), "file-utils-test-" + Date.now())
// Create a directory structure that mimics the real-world scenario:
//
// testDir/
// ├── repo/
// │ ├── skills/
// │ │ └── category/
// │ │ └── my-skill/
// │ │ └── SKILL.md
// │ └── .opencode/
// │ └── skills/
// │ └── my-skill -> ../../skills/category/my-skill (relative symlink)
// └── config/
// └── skills -> ../repo/.opencode/skills (absolute symlink)
const realSkillDir = join(testDir, "repo", "skills", "category", "my-skill")
const repoOpencodeSkills = join(testDir, "repo", ".opencode", "skills")
const configSkills = join(testDir, "config", "skills")
beforeAll(() => {
// Create real skill directory with a file
mkdirSync(realSkillDir, { recursive: true })
writeFileSync(join(realSkillDir, "SKILL.md"), "# My Skill")
// Create .opencode/skills/ with a relative symlink to the real skill
mkdirSync(repoOpencodeSkills, { recursive: true })
symlinkSync("../../skills/category/my-skill", join(repoOpencodeSkills, "my-skill"))
// Create config/skills as an absolute symlink to .opencode/skills
mkdirSync(join(testDir, "config"), { recursive: true })
symlinkSync(repoOpencodeSkills, configSkills)
})
afterAll(() => {
rmSync(testDir, { recursive: true, force: true })
})
describe("resolveSymlink", () => {
it("resolves a regular file path to itself", () => {
const filePath = join(realSkillDir, "SKILL.md")
expect(resolveSymlink(filePath)).toBe(filePath)
})
it("resolves a relative symlink to its real path", () => {
const symlinkPath = join(repoOpencodeSkills, "my-skill")
expect(resolveSymlink(symlinkPath)).toBe(realSkillDir)
})
it("resolves a chained symlink (symlink-to-dir-containing-symlinks) to the real path", () => {
// This is the real-world scenario:
// config/skills/my-skill -> (follows config/skills) -> repo/.opencode/skills/my-skill -> repo/skills/category/my-skill
const chainedPath = join(configSkills, "my-skill")
expect(resolveSymlink(chainedPath)).toBe(realSkillDir)
})
it("returns the original path for non-existent paths", () => {
const fakePath = join(testDir, "does-not-exist")
expect(resolveSymlink(fakePath)).toBe(fakePath)
})
})
describe("resolveSymlinkAsync", () => {
it("resolves a regular file path to itself", async () => {
const filePath = join(realSkillDir, "SKILL.md")
expect(await resolveSymlinkAsync(filePath)).toBe(filePath)
})
it("resolves a relative symlink to its real path", async () => {
const symlinkPath = join(repoOpencodeSkills, "my-skill")
expect(await resolveSymlinkAsync(symlinkPath)).toBe(realSkillDir)
})
it("resolves a chained symlink (symlink-to-dir-containing-symlinks) to the real path", async () => {
const chainedPath = join(configSkills, "my-skill")
expect(await resolveSymlinkAsync(chainedPath)).toBe(realSkillDir)
})
it("returns the original path for non-existent paths", async () => {
const fakePath = join(testDir, "does-not-exist")
expect(await resolveSymlinkAsync(fakePath)).toBe(fakePath)
})
})
describe("isSymbolicLink", () => {
it("returns true for a symlink", () => {
expect(isSymbolicLink(join(repoOpencodeSkills, "my-skill"))).toBe(true)
})
it("returns false for a regular directory", () => {
expect(isSymbolicLink(realSkillDir)).toBe(false)
})
it("returns false for a non-existent path", () => {
expect(isSymbolicLink(join(testDir, "does-not-exist"))).toBe(false)
})
})
+1 -34
View File
@@ -1,34 +1 @@
import { lstatSync, realpathSync } from "fs"
import { promises as fs } from "fs"
function normalizeDarwinRealpath(filePath: string): string {
return filePath.startsWith("/private/var/") ? filePath.slice("/private".length) : filePath
}
export function isMarkdownFile(entry: { name: string; isFile: () => 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<string> {
try {
return normalizeDarwinRealpath(await fs.realpath(filePath))
} catch {
return filePath
}
}
export { isMarkdownFile, isSymbolicLink, resolveSymlink, resolveSymlinkAsync } from "@oh-my-opencode/utils"
-265
View File
@@ -1,265 +0,0 @@
import { describe, test, expect } from "bun:test"
import { parseFrontmatter } from "./frontmatter"
describe("parseFrontmatter", () => {
// #region backward compatibility
test("parses simple key-value frontmatter", () => {
// given
const content = `---
description: Test command
agent: build
---
Body content`
// when
const result = parseFrontmatter(content)
// then
expect(result.data.description).toBe("Test command")
expect(result.data.agent).toBe("build")
expect(result.body).toBe("Body content")
})
test("parses boolean values", () => {
// given
const content = `---
subtask: true
enabled: false
---
Body`
// when
const result = parseFrontmatter<{ subtask: boolean; enabled: boolean }>(content)
// then
expect(result.data.subtask).toBe(true)
expect(result.data.enabled).toBe(false)
})
// #endregion
// #region complex YAML (handoffs support)
test("parses complex array frontmatter (speckit handoffs)", () => {
// given
const content = `---
description: Execute planning workflow
handoffs:
- label: Create Tasks
agent: speckit.tasks
prompt: Break the plan into tasks
send: true
- label: Create Checklist
agent: speckit.checklist
prompt: Create a checklist
---
Workflow instructions`
interface TestMeta {
description: string
handoffs: Array<{ label: string; agent: string; prompt: string; send?: boolean }>
}
// when
const result = parseFrontmatter<TestMeta>(content)
// then
expect(result.data.description).toBe("Execute planning workflow")
expect(result.data.handoffs).toHaveLength(2)
expect(result.data.handoffs[0].label).toBe("Create Tasks")
expect(result.data.handoffs[0].agent).toBe("speckit.tasks")
expect(result.data.handoffs[0].send).toBe(true)
expect(result.data.handoffs[1].agent).toBe("speckit.checklist")
expect(result.data.handoffs[1].send).toBeUndefined()
})
test("parses nested objects in frontmatter", () => {
// given
const content = `---
name: test
config:
timeout: 5000
retry: true
options:
verbose: false
---
Content`
interface TestMeta {
name: string
config: {
timeout: number
retry: boolean
options: { verbose: boolean }
}
}
// when
const result = parseFrontmatter<TestMeta>(content)
// then
expect(result.data.name).toBe("test")
expect(result.data.config.timeout).toBe(5000)
expect(result.data.config.retry).toBe(true)
expect(result.data.config.options.verbose).toBe(false)
})
// #endregion
// #region edge cases
test("handles content without frontmatter", () => {
// given
const content = "Just body content"
// when
const result = parseFrontmatter(content)
// then
expect(result.data).toEqual({})
expect(result.body).toBe("Just body content")
})
test("handles empty frontmatter", () => {
// given
const content = `---
---
Body`
// when
const result = parseFrontmatter(content)
// then
expect(result.data).toEqual({})
expect(result.body).toBe("Body")
})
test("handles invalid YAML gracefully", () => {
// given
const content = `---
invalid: yaml: syntax: here
bad indentation
---
Body`
// when
const result = parseFrontmatter(content)
// then - should not throw, return empty data
expect(result.data).toEqual({})
expect(result.body).toBe("Body")
})
test("handles frontmatter with only whitespace", () => {
// given
const content = `---
---
Body with whitespace-only frontmatter`
// when
const result = parseFrontmatter(content)
// then
expect(result.data).toEqual({})
expect(result.body).toBe("Body with whitespace-only frontmatter")
})
// #endregion
// #region mixed content
test("preserves multiline body content", () => {
// given
const content = `---
title: Test
---
Line 1
Line 2
Line 4 after blank`
// when
const result = parseFrontmatter<{ title: string }>(content)
// then
expect(result.data.title).toBe("Test")
expect(result.body).toBe("Line 1\nLine 2\n\nLine 4 after blank")
})
test("handles CRLF line endings", () => {
// given
const content = "---\r\ndescription: Test\r\n---\r\nBody"
// when
const result = parseFrontmatter<{ description: string }>(content)
// then
expect(result.data.description).toBe("Test")
expect(result.body).toBe("Body")
})
// #endregion
// #region extra fields tolerance
test("allows extra fields beyond typed interface", () => {
// given
const content = `---
description: Test command
agent: build
extra_field: should not fail
another_extra:
nested: value
array:
- item1
- item2
custom_boolean: true
custom_number: 42
---
Body content`
interface MinimalMeta {
description: string
agent: string
}
interface FrontmatterWithExtras extends MinimalMeta {
extra_field: string
another_extra: { nested: string; array: string[] }
custom_boolean: boolean
custom_number: number
}
// when
const result = parseFrontmatter<FrontmatterWithExtras>(content)
// then
expect(result.data.description).toBe("Test command")
expect(result.data.agent).toBe("build")
expect(result.body).toBe("Body content")
expect(result.data.extra_field).toBe("should not fail")
expect(result.data.another_extra).toEqual({ nested: "value", array: ["item1", "item2"] })
expect(result.data.custom_boolean).toBe(true)
expect(result.data.custom_number).toBe(42)
})
test("extra fields do not interfere with expected fields", () => {
// given
const content = `---
description: Original description
unknown_field: extra value
handoffs:
- label: Task 1
agent: test.agent
---
Content`
interface HandoffMeta {
description: string
handoffs: Array<{ label: string; agent: string }>
}
// when
const result = parseFrontmatter<HandoffMeta>(content)
// then
expect(result.data.description).toBe("Original description")
expect(result.data.handoffs).toHaveLength(1)
expect(result.data.handoffs[0].label).toBe("Task 1")
expect(result.data.handoffs[0].agent).toBe("test.agent")
})
// #endregion
})
+1 -31
View File
@@ -1,31 +1 @@
import yaml from "js-yaml"
export interface FrontmatterResult<T = Record<string, unknown>> {
data: T
body: string
hadFrontmatter: boolean
parseError: boolean
}
export function parseFrontmatter<T = Record<string, unknown>>(
content: string
): FrontmatterResult<T> {
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"
@@ -1,54 +0,0 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import * as fs from "node:fs"
import { join } from "node:path"
describe("detectPluginConfigFile memoization", () => {
const testDir = join(__dirname, ".test-detect-plugin-memoization")
afterEach(() => {
mock.restore()
})
test("returns cached result on repeated calls for the same directory", async () => {
// given
const existsSync = spyOn(fs, "existsSync").mockImplementation((filePath: fs.PathLike) => {
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 callsAfterFirstResult = existsSync.mock.calls.length
const secondResult = parserModule.detectPluginConfigFile(testDir)
// then
expect(firstResult).toEqual(secondResult)
expect(existsSync.mock.calls.length).toBe(callsAfterFirstResult)
expect(readdirSync).toHaveBeenCalledTimes(0)
})
test("clears cached result when requested", async () => {
// given
const existsSync = spyOn(fs, "existsSync").mockImplementation((filePath: fs.PathLike) => {
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.clearPluginConfigFileDetectionCache()
const callsAfterClear = existsSync.mock.calls.length
// when
parserModule.detectPluginConfigFile(testDir)
// then
expect(existsSync.mock.calls.length).toBeGreaterThan(callsAfterClear)
expect(readdirSync).toHaveBeenCalledTimes(0)
})
})
-438
View File
@@ -1,438 +0,0 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { clearPluginConfigFileDetectionCache, detectConfigFile, detectPluginConfigFile, parseJsonc, parseJsoncSafe, readJsoncFile } from "./jsonc-parser"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
describe("parseJsonc", () => {
test("parses plain JSON", () => {
// given
const json = `{"key": "value"}`
// when
const result = parseJsonc<{ key: string }>(json)
// then
expect(result.key).toBe("value")
})
test("parses JSONC with line comments", () => {
// given
const jsonc = `{
// This is a comment
"key": "value"
}`
// when
const result = parseJsonc<{ key: string }>(jsonc)
// then
expect(result.key).toBe("value")
})
test("parses JSONC with block comments", () => {
// given
const jsonc = `{
/* Block comment */
"key": "value"
}`
// when
const result = parseJsonc<{ key: string }>(jsonc)
// then
expect(result.key).toBe("value")
})
test("parses JSONC with multi-line block comments", () => {
// given
const jsonc = `{
/* Multi-line
comment
here */
"key": "value"
}`
// when
const result = parseJsonc<{ key: string }>(jsonc)
// then
expect(result.key).toBe("value")
})
test("parses JSONC with trailing commas", () => {
// given
const jsonc = `{
"key1": "value1",
"key2": "value2",
}`
// when
const result = parseJsonc<{ key1: string; key2: string }>(jsonc)
// then
expect(result.key1).toBe("value1")
expect(result.key2).toBe("value2")
})
test("parses JSONC with trailing comma in array", () => {
// given
const jsonc = `{
"arr": [1, 2, 3,]
}`
// when
const result = parseJsonc<{ arr: number[] }>(jsonc)
// then
expect(result.arr).toEqual([1, 2, 3])
})
test("preserves URLs with // in strings", () => {
// given
const jsonc = `{
"url": "https://example.com"
}`
// when
const result = parseJsonc<{ url: string }>(jsonc)
// then
expect(result.url).toBe("https://example.com")
})
test("parses complex JSONC config", () => {
// given
const jsonc = `{
// This is an example config
"agents": {
"oracle": { "model": "openai/gpt-5.4" }, // GPT for strategic reasoning
},
/* Agent overrides */
"disabled_agents": [],
}`
// when
const result = parseJsonc<{
agents: { oracle: { model: string } }
disabled_agents: string[]
}>(jsonc)
// then
expect(result.agents.oracle.model).toBe("openai/gpt-5.4")
expect(result.disabled_agents).toEqual([])
})
test("throws on invalid JSON", () => {
// given
const invalid = `{ "key": invalid }`
// when
// then
expect(() => parseJsonc(invalid)).toThrow()
})
test("throws on unclosed string", () => {
// given
const invalid = `{ "key": "unclosed }`
// when
// then
expect(() => parseJsonc(invalid)).toThrow()
})
test("parses content with UTF-8 BOM prefix", () => {
// given
const jsonc = `\uFEFF{"key": "value"}`
// when
const result = parseJsonc<{ key: string }>(jsonc)
// then
expect(result.key).toBe("value")
})
test("parses commented JSONC with UTF-8 BOM prefix", () => {
// given
const jsonc = `\uFEFF{
// Windows-saved file with BOM
"$schema": "https://opencode.ai/config.json",
"plugin": ["oh-my-openagent@3.15.3"],
}`
// when
const result = parseJsonc<{ $schema: string; plugin: string[] }>(jsonc)
// then
expect(result.$schema).toBe("https://opencode.ai/config.json")
expect(result.plugin).toEqual(["oh-my-openagent@3.15.3"])
})
})
describe("parseJsoncSafe", () => {
test("returns data on valid JSONC", () => {
// given
const jsonc = `{ "key": "value" }`
// when
const result = parseJsoncSafe<{ key: string }>(jsonc)
// then
expect(result.data).not.toBeNull()
expect(result.data?.key).toBe("value")
expect(result.errors).toHaveLength(0)
})
test("returns errors on invalid JSONC", () => {
// given
const invalid = `{ "key": invalid }`
// when
const result = parseJsoncSafe(invalid)
// then
expect(result.data).toBeNull()
expect(result.errors.length).toBeGreaterThan(0)
})
test("returns data when content has UTF-8 BOM prefix", () => {
// given
const jsonc = `\uFEFF{"key": "value"}`
// when
const result = parseJsoncSafe<{ key: string }>(jsonc)
// then
expect(result.errors).toHaveLength(0)
expect(result.data).not.toBeNull()
expect(result.data?.key).toBe("value")
})
})
describe("readJsoncFile", () => {
const testDir = join(__dirname, ".test-jsonc")
const testFile = join(testDir, "config.jsonc")
test("reads and parses valid JSONC file", () => {
// given
if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true })
const content = `{
// Comment
"test": "value"
}`
writeFileSync(testFile, content)
// when
const result = readJsoncFile<{ test: string }>(testFile)
// then
expect(result).not.toBeNull()
expect(result?.test).toBe("value")
rmSync(testDir, { recursive: true, force: true })
})
test("returns null for non-existent file", () => {
// given
const nonExistent = join(testDir, "does-not-exist.jsonc")
// when
const result = readJsoncFile(nonExistent)
// then
expect(result).toBeNull()
})
test("returns null for malformed JSON", () => {
// given
if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true })
writeFileSync(testFile, "{ invalid }")
// when
const result = readJsoncFile(testFile)
// then
expect(result).toBeNull()
rmSync(testDir, { recursive: true, force: true })
})
test("reads JSONC file written with UTF-8 BOM (Windows scenario)", () => {
// given
if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true })
const bomBytes = Buffer.from([0xef, 0xbb, 0xbf])
const jsonBytes = Buffer.from(`{
// Created on Windows with BOM
"$schema": "https://opencode.ai/config.json",
"plugin": ["oh-my-openagent@3.15.3"]
}`)
writeFileSync(testFile, Buffer.concat([bomBytes, jsonBytes]))
// when
const result = readJsoncFile<{ $schema: string; plugin: string[] }>(testFile)
// then
expect(result).not.toBeNull()
expect(result?.$schema).toBe("https://opencode.ai/config.json")
expect(result?.plugin).toEqual(["oh-my-openagent@3.15.3"])
rmSync(testDir, { recursive: true, force: true })
})
})
describe("detectConfigFile", () => {
const testDir = join(__dirname, ".test-detect")
test("prefers .jsonc over .json", () => {
// given
if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true })
const basePath = join(testDir, "config")
writeFileSync(`${basePath}.json`, "{}")
writeFileSync(`${basePath}.jsonc`, "{}")
// when
const result = detectConfigFile(basePath)
// then
expect(result.format).toBe("jsonc")
expect(result.path).toBe(`${basePath}.jsonc`)
rmSync(testDir, { recursive: true, force: true })
})
test("detects .json when .jsonc doesn't exist", () => {
// given
if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true })
const basePath = join(testDir, "config")
writeFileSync(`${basePath}.json`, "{}")
// when
const result = detectConfigFile(basePath)
// then
expect(result.format).toBe("json")
expect(result.path).toBe(`${basePath}.json`)
rmSync(testDir, { recursive: true, force: true })
})
test("returns none when neither exists", () => {
// given
const basePath = join(testDir, "nonexistent")
// when
const result = detectConfigFile(basePath)
// then
expect(result.format).toBe("none")
})
})
describe("detectPluginConfigFile", () => {
const testDir = join(__dirname, ".test-detect-plugin")
beforeEach(() => {
clearPluginConfigFileDetectionCache()
})
afterEach(() => {
clearPluginConfigFileDetectionCache()
})
test("prefers oh-my-openagent over oh-my-opencode when both jsonc files exist", () => {
// given
if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true })
writeFileSync(join(testDir, "oh-my-openagent.jsonc"), "{}")
writeFileSync(join(testDir, "oh-my-opencode.jsonc"), "{}")
// when
const result = detectPluginConfigFile(testDir)
// then
expect(result.format).toBe("jsonc")
expect(result.path).toBe(join(testDir, "oh-my-openagent.jsonc"))
expect(result.legacyPath).toBe(join(testDir, "oh-my-opencode.jsonc"))
rmSync(testDir, { recursive: true, force: true })
})
test("falls back to oh-my-opencode when oh-my-openagent doesn't exist", () => {
// given
if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true })
writeFileSync(join(testDir, "oh-my-opencode.jsonc"), "{}")
// when
const result = detectPluginConfigFile(testDir)
// then
expect(result.format).toBe("jsonc")
expect(result.path).toBe(join(testDir, "oh-my-opencode.jsonc"))
expect(result.legacyPath).toBeUndefined()
rmSync(testDir, { recursive: true, force: true })
})
test("loads oh-my-openagent.json before oh-my-opencode.json when no jsonc exists", () => {
// given
if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true })
writeFileSync(join(testDir, "oh-my-openagent.json"), "{}")
writeFileSync(join(testDir, "oh-my-opencode.json"), "{}")
// when
const result = detectPluginConfigFile(testDir)
// then
expect(result.format).toBe("json")
expect(result.path).toBe(join(testDir, "oh-my-openagent.json"))
expect(result.legacyPath).toBe(join(testDir, "oh-my-opencode.json"))
rmSync(testDir, { recursive: true, force: true })
})
test("returns none when no config files exist", () => {
// given
const emptyDir = join(testDir, "empty")
if (!existsSync(emptyDir)) mkdirSync(emptyDir, { recursive: true })
// when
const result = detectPluginConfigFile(emptyDir)
// then
expect(result.format).toBe("none")
expect(result.path).toBe(join(emptyDir, "oh-my-openagent.json"))
rmSync(testDir, { recursive: true, force: true })
})
test("prefers canonical jsonc over legacy json when both exist", () => {
// given
if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true })
writeFileSync(join(testDir, "oh-my-opencode.json"), "{}")
writeFileSync(join(testDir, "oh-my-openagent.jsonc"), "{}")
// when
const result = detectPluginConfigFile(testDir)
// then
expect(result.format).toBe("jsonc")
expect(result.path).toBe(join(testDir, "oh-my-openagent.jsonc"))
expect(result.legacyPath).toBe(join(testDir, "oh-my-opencode.json"))
rmSync(testDir, { recursive: true, force: true })
})
test("loads oh-my-openagent when only canonical jsonc exists", () => {
// given
if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true })
writeFileSync(join(testDir, "oh-my-openagent.jsonc"), "{}")
// when
const result = detectPluginConfigFile(testDir)
// then
expect(result.format).toBe("jsonc")
expect(result.path).toBe(join(testDir, "oh-my-openagent.jsonc"))
expect(result.legacyPath).toBeUndefined()
rmSync(testDir, { recursive: true, force: true })
})
})
+10 -116
View File
@@ -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<T> {
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<string, DetectPluginConfigResult>()
function stripBom(content: string): string {
return content.charCodeAt(0) === 0xfeff ? content.slice(1) : content
}
export function parseJsonc<T = unknown>(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<T = unknown>(content: string): JsoncParseResult<T> {
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<T = unknown>(filePath: string): T | null {
try {
const content = readFileSync(filePath, "utf-8")
return parseJsonc<T>(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"
-425
View File
@@ -1,425 +0,0 @@
import { createServer, Server } from "node:net"
import type { AddressInfo } from "node:net"
import { networkInterfaces } from "node:os"
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"
import { DEFAULT_SERVER_PORT, findAvailablePort, getAvailableServerPort, isPortAvailable } from "./port-utils"
const DEFAULT_HOSTNAME = "127.0.0.1"
const MAX_PORT_ATTEMPTS = 20
const EXHAUSTED_PORT_COUNT = MAX_PORT_ATTEMPTS + 1
const CONTIGUOUS_SEARCH_WINDOW = 256
const CONTIGUOUS_SEARCH_SEEDS = 8
const trackedServers = new Set<Server>()
type TimeoutProbeResult = {
closeCallCount: number
isAvailable: boolean
server: Server | undefined
}
function getRequiredPropertyDescriptor(target: object, propertyName: string): PropertyDescriptor {
const descriptor = Object.getOwnPropertyDescriptor(target, propertyName)
if (!descriptor) {
throw new Error(`Expected ${propertyName} property descriptor`)
}
return descriptor
}
function isTcpAddress(address: ReturnType<Server["address"]>): address is AddressInfo {
return typeof address === "object" && address !== null && "port" in address
}
function getServerPort(server: Server): number {
const address = server.address()
if (!isTcpAddress(address)) {
throw new Error("Expected TCP server address")
}
return address.port
}
function getAlternateIpv4Hostname(): string | undefined {
for (const addresses of Object.values(networkInterfaces())) {
if (!addresses) continue
for (const address of addresses) {
if (address.family === "IPv4" && !address.internal && address.address !== DEFAULT_HOSTNAME) {
return address.address
}
}
}
return undefined
}
function startTrackedServer(port: number, hostname: string = DEFAULT_HOSTNAME): Promise<Server> {
return new Promise<Server>((resolve, reject) => {
const server = createServer()
const removeListeners = (): void => {
server.removeListener("error", handleError)
server.removeListener("listening", handleListening)
}
const handleError = (error: Error): void => {
removeListeners()
trackedServers.delete(server)
reject(error)
}
const handleListening = (): void => {
removeListeners()
trackedServers.add(server)
resolve(server)
}
server.once("error", handleError)
server.once("listening", handleListening)
try {
server.listen(port, hostname)
} catch (error) {
removeListeners()
trackedServers.delete(server)
reject(error)
}
})
}
function closeTrackedServer(server: Server): Promise<void> {
trackedServers.delete(server)
if (!server.listening) {
return Promise.resolve()
}
return new Promise<void>((resolve, reject) => {
server.close((error?: Error) => {
if (error) {
reject(error)
return
}
resolve()
})
})
}
async function closeAllTrackedServers(): Promise<void> {
await Promise.all(Array.from(trackedServers).map((server) => closeTrackedServer(server)))
}
async function getReleasedPort(hostname: string = DEFAULT_HOSTNAME): Promise<number> {
const server = await startTrackedServer(0, hostname)
const port = getServerPort(server)
await closeTrackedServer(server)
return port
}
async function canBindContiguousPorts(
startPort: number,
portCount: number,
hostname: string = DEFAULT_HOSTNAME
): Promise<boolean> {
const servers: Server[] = []
try {
for (let offset = 0; offset < portCount; offset++) {
servers.push(await startTrackedServer(startPort + offset, hostname))
}
return true
} catch {
return false
} finally {
await Promise.all(servers.map((server) => closeTrackedServer(server)))
}
}
async function findContiguousAvailableStart(
portCount: number,
hostname: string = DEFAULT_HOSTNAME
): Promise<number> {
for (let seedAttempt = 0; seedAttempt < CONTIGUOUS_SEARCH_SEEDS; seedAttempt++) {
const seedPort = await getReleasedPort(hostname)
const maxStartPort = Math.min(65_535 - portCount + 1, seedPort + CONTIGUOUS_SEARCH_WINDOW)
for (let candidatePort = seedPort; candidatePort <= maxStartPort; candidatePort++) {
if (await canBindContiguousPorts(candidatePort, portCount, hostname)) {
return candidatePort
}
}
}
throw new Error(`Could not find ${portCount} contiguous available ports`)
}
async function startAlternateInterfaceBlockerWithDefaultHostFree(hostname: string): Promise<Server | undefined> {
for (let seedAttempt = 0; seedAttempt < CONTIGUOUS_SEARCH_SEEDS; seedAttempt++) {
const seedPort = await getReleasedPort(hostname)
const maxStartPort = Math.min(65_535, seedPort + CONTIGUOUS_SEARCH_WINDOW)
for (let candidatePort = seedPort; candidatePort <= maxStartPort; candidatePort++) {
let blocker: Server | undefined
try {
blocker = await startTrackedServer(candidatePort, hostname)
const defaultHostProbe = await startTrackedServer(candidatePort, DEFAULT_HOSTNAME)
await closeTrackedServer(defaultHostProbe)
return blocker
} catch {
if (blocker) {
await closeTrackedServer(blocker)
}
}
}
}
return undefined
}
async function startConsecutiveBlockers(
startPort: number,
portCount: number,
hostname: string = DEFAULT_HOSTNAME
): Promise<Server[]> {
const servers: Server[] = []
try {
for (let offset = 0; offset < portCount; offset++) {
servers.push(await startTrackedServer(startPort + offset, hostname))
}
return servers
} catch (error) {
await Promise.all(servers.map((server) => closeTrackedServer(server)))
throw error
}
}
async function captureDefaultListenHostname(port: number): Promise<string | undefined> {
const listenDescriptor = getRequiredPropertyDescriptor(Server.prototype, "listen")
const closeDescriptor = getRequiredPropertyDescriptor(Server.prototype, "close")
let capturedHostname: string | undefined
Object.defineProperty(Server.prototype, "listen", {
configurable: true,
value: function listenAndCaptureHostname(this: Server, requestedPort: number, hostname?: string): Server {
if (requestedPort === port) {
capturedHostname = hostname
}
queueMicrotask(() => this.emit("listening"))
return this
},
})
Object.defineProperty(Server.prototype, "close", {
configurable: true,
value: function closeCapturedServer(this: Server, callback?: (error?: Error) => void): Server {
queueMicrotask(() => callback?.())
return this
},
})
try {
await isPortAvailable(port)
return capturedHostname
} finally {
Object.defineProperty(Server.prototype, "listen", listenDescriptor)
Object.defineProperty(Server.prototype, "close", closeDescriptor)
}
}
async function runTimedOutAvailabilityProbe(port: number): Promise<TimeoutProbeResult> {
const setTimeoutDescriptor = getRequiredPropertyDescriptor(globalThis, "setTimeout")
const listenDescriptor = getRequiredPropertyDescriptor(Server.prototype, "listen")
const closeDescriptor = getRequiredPropertyDescriptor(Server.prototype, "close")
const originalSetTimeout = globalThis.setTimeout
let timedOutServer: Server | undefined
let closeCallCount = 0
Object.defineProperty(globalThis, "setTimeout", {
configurable: true,
value: (callback: () => void): ReturnType<typeof setTimeout> => originalSetTimeout(callback, 0),
})
Object.defineProperty(Server.prototype, "listen", {
configurable: true,
value: function listenWithoutEmitting(this: Server): Server {
timedOutServer = this
return this
},
})
Object.defineProperty(Server.prototype, "close", {
configurable: true,
value: function closeTimedOutServer(this: Server, callback?: (error?: Error) => void): Server {
closeCallCount++
queueMicrotask(() => callback?.())
return this
},
})
try {
const isAvailable = await isPortAvailable(port)
return { closeCallCount, isAvailable, server: timedOutServer }
} finally {
Object.defineProperty(globalThis, "setTimeout", setTimeoutDescriptor)
Object.defineProperty(Server.prototype, "listen", listenDescriptor)
Object.defineProperty(Server.prototype, "close", closeDescriptor)
}
}
describe("port-utils", () => {
beforeAll(() => {
trackedServers.clear()
})
afterEach(async () => {
await closeAllTrackedServers()
})
afterAll(async () => {
await closeAllTrackedServers()
})
describe("#given isPortAvailable", () => {
test("#when a released port is checked #then returns true", async () => {
const port = await getReleasedPort()
const result = await isPortAvailable(port)
expect(result).toBe(true)
})
test("#when an already bound port is checked #then returns false", async () => {
const blocker = await startTrackedServer(0)
const port = getServerPort(blocker)
const result = await isPortAvailable(port)
expect(result).toBe(false)
})
test("#when a timed out probe is cleaned up #then no listeners or server remain active", async () => {
const port = await getReleasedPort()
const result = await runTimedOutAvailabilityProbe(port)
expect(result.isAvailable).toBe(false)
expect(result.closeCallCount).toBe(1)
expect(result.server).toBeDefined()
if (!result.server) {
throw new Error("Expected timed out server")
}
expect(result.server.listening).toBe(false)
expect(result.server.listenerCount("error")).toBe(0)
expect(result.server.listenerCount("listening")).toBe(0)
})
test("#when a successful probe finishes #then the port can be rebound immediately", async () => {
const port = await getReleasedPort()
const result = await isPortAvailable(port)
const server = await startTrackedServer(port)
expect(result).toBe(true)
expect(getServerPort(server)).toBe(port)
})
test("#when hostname is omitted #then 127.0.0.1 is the default target", async () => {
const blocker = await startTrackedServer(0, DEFAULT_HOSTNAME)
const port = getServerPort(blocker)
const result = await isPortAvailable(port)
expect(result).toBe(false)
})
test("#when another interface owns the port #then default probing does not bind all interfaces", async () => {
const alternateHostname = getAlternateIpv4Hostname()
if (!alternateHostname) {
const port = await getReleasedPort()
const capturedHostname = await captureDefaultListenHostname(port)
expect(capturedHostname).toBe(DEFAULT_HOSTNAME)
return
}
const blocker = await startAlternateInterfaceBlockerWithDefaultHostFree(alternateHostname)
if (!blocker) {
const port = await getReleasedPort()
const capturedHostname = await captureDefaultListenHostname(port)
expect(capturedHostname).toBe(DEFAULT_HOSTNAME)
return
}
const port = getServerPort(blocker)
expect(await isPortAvailable(port)).toBe(true)
expect(await isPortAvailable(port, alternateHostname)).toBe(false)
})
})
describe("#given findAvailablePort", () => {
test("#when the start port is available #then returns the start port", async () => {
const startPort = await findContiguousAvailableStart(1)
const result = await findAvailablePort(startPort)
expect(result).toBe(startPort)
})
test("#when the first three ports are blocked #then returns the next free port", async () => {
const startPort = await findContiguousAvailableStart(4)
await startConsecutiveBlockers(startPort, 3)
const result = await findAvailablePort(startPort)
expect(result).toBe(startPort + 3)
})
test("#when every attempted port is blocked #then throws", async () => {
const startPort = await findContiguousAvailableStart(EXHAUSTED_PORT_COUNT)
await startConsecutiveBlockers(startPort, EXHAUSTED_PORT_COUNT)
let errorMessage: string | undefined
try {
await findAvailablePort(startPort)
} catch (error) {
if (!(error instanceof Error)) {
throw error
}
errorMessage = error.message
}
expect(errorMessage).toBe(`No available port found in range ${startPort}-${startPort + MAX_PORT_ATTEMPTS - 1}`)
})
})
describe("#given getAvailableServerPort", () => {
test("#when the preferred port is free #then returns the preferred port without auto-selection", async () => {
const preferredPort = await findContiguousAvailableStart(1)
const result = await getAvailableServerPort(preferredPort)
expect(result).toEqual({ port: preferredPort, wasAutoSelected: false })
})
test("#when the preferred port is blocked #then returns the next port with auto-selection", async () => {
const preferredPort = await findContiguousAvailableStart(2)
await startTrackedServer(preferredPort)
const result = await getAvailableServerPort(preferredPort)
expect(result).toEqual({ port: preferredPort + 1, wasAutoSelected: true })
})
})
describe("#given DEFAULT_SERVER_PORT", () => {
test("#when accessed #then returns 4096", () => {
expect(DEFAULT_SERVER_PORT).toBe(4096)
})
})
})
+7 -83
View File
@@ -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<boolean> {
return new Promise<boolean>((resolve) => {
const server = createServer()
let timeoutId: ReturnType<typeof setTimeout> | 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<number> {
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<AutoPortResult> {
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"
+5 -1
View File
@@ -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<string, string | undefined>()
@@ -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)
+1 -3
View File
@@ -1,3 +1 @@
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
export { isRecord } from "@oh-my-opencode/utils"
@@ -1,62 +0,0 @@
import { describe, it, expect } from "bun:test"
import { readdir, readFile } from "node:fs/promises"
import { join, relative } from "node:path"
const SRC_DIR = join(import.meta.dir, "..")
async function collectTsFiles(dir: string): Promise<string[]> {
const results: string[] = []
const entries = await readdir(dir, { withFileTypes: true })
for (const entry of entries) {
const fullPath = join(dir, entry.name)
if (entry.isDirectory()) {
if (entry.name === "node_modules" || entry.name === "dist") continue
results.push(...(await collectTsFiles(fullPath)))
} else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts") && !entry.name.endsWith(".audit.test.ts")) {
results.push(fullPath)
}
}
return results
}
const HELPER_FILE = "shared/replace-tool-args.ts"
// Matches direct mutations like `output.args.foo =` or `toolOutput.args.foo =`
// but excludes comparisons (===, !==, ==)
const DIRECT_MUTATION_PATTERN = /\w*[Oo]utput\.args\.\w+\s*=[^=]/g
const OBJECT_ASSIGN_PATTERN = /Object\.assign\(\s*\w*[Oo]utput\.args/g
describe("replace-tool-args audit", () => {
it("#given src/**/*.ts files #when scanning for direct output.args mutation #then no matches found outside the helper", async () => {
// given
const files = await collectTsFiles(SRC_DIR)
const violations: string[] = []
// when
for (const file of files) {
const relPath = relative(SRC_DIR, file)
if (relPath === HELPER_FILE) continue
const content = await readFile(file, "utf-8")
const lines = content.split("\n")
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (DIRECT_MUTATION_PATTERN.test(line)) {
violations.push(`${relPath}:${i + 1}: ${line.trim()}`)
}
DIRECT_MUTATION_PATTERN.lastIndex = 0
if (OBJECT_ASSIGN_PATTERN.test(line)) {
violations.push(`${relPath}:${i + 1}: ${line.trim()}`)
}
OBJECT_ASSIGN_PATTERN.lastIndex = 0
}
}
// then
expect(violations).toEqual([])
})
})
-148
View File
@@ -1,148 +0,0 @@
import { describe, it, expect } from "bun:test"
import { replaceToolArgs } from "./replace-tool-args"
describe("replaceToolArgs", () => {
describe("#given a mutable output.args object", () => {
it("#when patching a single property #then the output.args contains the patched value", () => {
// given
const output = { args: { command: "git status", timeout: 30 } as Record<string, unknown> }
// when
replaceToolArgs(output, { command: "git log" })
// then
expect(output.args.command).toBe("git log")
expect(output.args.timeout).toBe(30)
})
it("#when patching multiple properties #then all patched values are present", () => {
// given
const output = { args: { url: "http://old.com", format: "text" } as Record<string, unknown> }
// when
replaceToolArgs(output, { url: "http://new.com", format: "markdown" })
// then
expect(output.args.url).toBe("http://new.com")
expect(output.args.format).toBe("markdown")
})
it("#when patching #then the original args object is not the same reference", () => {
// given
const originalArgs = { command: "echo hi" } as Record<string, unknown>
const output = { args: originalArgs }
// when
replaceToolArgs(output, { command: "echo bye" })
// then
expect(output.args).not.toBe(originalArgs)
expect(originalArgs.command).toBe("echo hi")
})
})
describe("#given a frozen output.args object", () => {
it("#when patching a single property #then no TypeError is thrown and the value is updated", () => {
// given
const output = { args: Object.freeze({ command: "git status", timeout: 30 }) as Record<string, unknown> }
// when / then
expect(() => replaceToolArgs(output, { command: "git log" })).not.toThrow()
expect(output.args.command).toBe("git log")
expect(output.args.timeout).toBe(30)
})
it("#when patching with Object-typed value #then no TypeError is thrown", () => {
// given
const output = { args: Object.freeze({ todos: "[]" }) as Record<string, unknown> }
const parsed = [{ id: "1", content: "test", status: "pending" }]
// when / then
expect(() => replaceToolArgs(output, { todos: parsed })).not.toThrow()
expect(output.args.todos).toEqual(parsed)
})
it("#when patching url on frozen webfetch args #then no TypeError is thrown", () => {
// given
const output = { args: Object.freeze({ url: "http://old.com", format: "markdown" }) as Record<string, unknown> }
// when / then
expect(() => replaceToolArgs(output, { url: "http://redirected.com" })).not.toThrow()
expect(output.args.url).toBe("http://redirected.com")
expect(output.args.format).toBe("markdown")
})
it("#when patching command with env prefix on frozen bash args #then no TypeError is thrown", () => {
// given
const output = { args: Object.freeze({ command: "git rebase --continue" }) as Record<string, unknown> }
// when / then
expect(() => replaceToolArgs(output, { command: "GIT_EDITOR=: git rebase --continue" })).not.toThrow()
expect(output.args.command).toBe("GIT_EDITOR=: git rebase --continue")
})
it("#when patching prompt on frozen task args #then no TypeError is thrown", () => {
// given
const output = { args: Object.freeze({ prompt: "Do the thing", category: "quick" }) as Record<string, unknown> }
// when / then
expect(() => replaceToolArgs(output, { prompt: "[DIRECTIVE] Do the thing" })).not.toThrow()
expect(output.args.prompt).toBe("[DIRECTIVE] Do the thing")
expect(output.args.category).toBe("quick")
})
it("#when stripping null bytes from frozen command #then no TypeError is thrown", () => {
// given
const frozenCommand = "echo \x00hello"
const output = { args: Object.freeze({ command: frozenCommand }) as Record<string, unknown> }
// when / then
expect(() => replaceToolArgs(output, { command: "echo hello" })).not.toThrow()
expect(output.args.command).toBe("echo hello")
})
it("#when replacing truncated question labels on frozen args #then no TypeError is thrown", () => {
// given
const output = {
args: Object.freeze({
questions: [{ question: "Pick", options: [{ label: "A very long label that should be truncated" }] }],
}) as Record<string, unknown>,
}
const truncated = {
questions: [{ question: "Pick", options: [{ label: "A very long label that sho..." }] }],
}
// when / then
expect(() => replaceToolArgs(output, truncated)).not.toThrow()
expect((output.args.questions as Array<{ options: Array<{ label: string }> }>)[0].options[0].label).toBe(
"A very long label that sho...",
)
})
it("#when replacing modifiedInput from PreToolUse hook on frozen args #then no TypeError is thrown", () => {
// given
const output = { args: Object.freeze({ filePath: "/old/path.ts" }) as Record<string, unknown> }
// when / then
expect(() => replaceToolArgs(output, { filePath: "/new/path.ts" })).not.toThrow()
expect(output.args.filePath).toBe("/new/path.ts")
})
it("#when replacing todo snapshot on frozen args #then no TypeError is thrown", () => {
// given
const output = {
args: Object.freeze({
todos: [{ content: "bootstrap", status: "pending" }],
}) as Record<string, unknown>,
}
const snapshot = [
{ content: "Real task 1", status: "in_progress" },
{ content: "Real task 2", status: "pending" },
]
// when / then
expect(() => replaceToolArgs(output, { todos: snapshot })).not.toThrow()
expect(output.args.todos).toEqual(snapshot)
})
})
})
+1 -16
View File
@@ -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<string, unknown> },
patch: Record<string, unknown>,
): void {
output.args = { ...output.args, ...patch }
}
export { replaceToolArgs } from "@oh-my-opencode/utils"
+1 -44
View File
@@ -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<string, unknown>,
transformer: (key: string) => string,
deep: boolean = true
): Record<string, unknown> {
const result: Record<string, unknown> = {}
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<string, unknown>,
deep: boolean = true
): Record<string, unknown> {
return transformObjectKeys(obj, camelToSnake, deep)
}
export function objectToCamelCase(
obj: Record<string, unknown>,
deep: boolean = true
): Record<string, unknown> {
return transformObjectKeys(obj, snakeToCamel, deep)
}
export { camelToSnake, objectToCamelCase, objectToSnakeCase, snakeToCamel, transformObjectKeys } from "@oh-my-opencode/utils"
-155
View File
@@ -1,155 +0,0 @@
import { describe, it, expect } from "bun:test"
import { transformToolName } from "./tool-name"
describe("transformToolName", () => {
describe("whitespace trimming", () => {
it("trims leading whitespace from tool name", () => {
// given
const toolName = " delegate_task"
// when
const result = transformToolName(toolName)
// then
expect(result).toBe("DelegateTask")
})
it("trims trailing whitespace from tool name", () => {
// given
const toolName = "delegate_task "
// when
const result = transformToolName(toolName)
// then
expect(result).toBe("DelegateTask")
})
it("trims both leading and trailing whitespace", () => {
// given
const toolName = " delegate_task "
// when
const result = transformToolName(toolName)
// then
expect(result).toBe("DelegateTask")
})
it("applies special mapping after trimming whitespace", () => {
// given
const toolName = " webfetch"
// when
const result = transformToolName(toolName)
// then
expect(result).toBe("WebFetch")
})
it("handles simple case with leading and trailing spaces", () => {
// given
const toolName = " read "
// when
const result = transformToolName(toolName)
// then
expect(result).toBe("Read")
})
})
describe("special tool mappings", () => {
it("maps webfetch to WebFetch", () => {
// given
const toolName = "webfetch"
// when
const result = transformToolName(toolName)
// then
expect(result).toBe("WebFetch")
})
it("maps websearch to WebSearch", () => {
// given
const toolName = "websearch"
// when
const result = transformToolName(toolName)
// then
expect(result).toBe("WebSearch")
})
it("maps todoread to TodoRead", () => {
// given
const toolName = "todoread"
// when
const result = transformToolName(toolName)
// then
expect(result).toBe("TodoRead")
})
it("maps todowrite to TodoWrite", () => {
// given
const toolName = "todowrite"
// when
const result = transformToolName(toolName)
// then
expect(result).toBe("TodoWrite")
})
})
describe("kebab-case and snake_case conversion", () => {
it("converts snake_case to PascalCase", () => {
// given
const toolName = "delegate_task"
// when
const result = transformToolName(toolName)
// then
expect(result).toBe("DelegateTask")
})
it("converts kebab-case to PascalCase", () => {
// given
const toolName = "call-omo-agent"
// when
const result = transformToolName(toolName)
// then
expect(result).toBe("CallOmoAgent")
})
})
describe("simple capitalization", () => {
it("capitalizes simple single-word tool names", () => {
// given
const toolName = "read"
// when
const result = transformToolName(toolName)
// then
expect(result).toBe("Read")
})
it("preserves capitalization of already capitalized names", () => {
// given
const toolName = "Write"
// when
const result = transformToolName(toolName)
// then
expect(result).toBe("Write")
})
})
})
+1 -27
View File
@@ -1,27 +1 @@
const SPECIAL_TOOL_MAPPINGS: Record<string, string> = {
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"