refactor(packages): extract utils package
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 +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 +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"
|
||||
|
||||
@@ -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 +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"
|
||||
|
||||
@@ -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 +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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
@@ -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 +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([])
|
||||
})
|
||||
})
|
||||
@@ -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 +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 +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"
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user