mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 02:49:57 -04:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f39c4bfff | |||
| 67b1d7ef36 | |||
| 07cdb2668c | |||
| be7a9c4987 | |||
| 47cb07a8cf | |||
| 5f5546ee9b | |||
| d3972f7107 | |||
| b9de3ad370 |
@@ -1,5 +1,5 @@
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import type { ZodType } from "zod"
|
import type { ZodObject, ZodRawShape } from "zod"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
|
|
||||||
export namespace BusEvent {
|
export namespace BusEvent {
|
||||||
@@ -9,7 +9,7 @@ export namespace BusEvent {
|
|||||||
|
|
||||||
const registry = new Map<string, Definition>()
|
const registry = new Map<string, Definition>()
|
||||||
|
|
||||||
export function define<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
|
export function define<Type extends string, Properties extends ZodObject<ZodRawShape>>(type: Type, properties: Properties) {
|
||||||
const result = {
|
const result = {
|
||||||
type,
|
type,
|
||||||
properties,
|
properties,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export const GlobalBus = new EventEmitter<{
|
|||||||
event: [
|
event: [
|
||||||
{
|
{
|
||||||
directory?: string
|
directory?: string
|
||||||
payload: any
|
payload: { type: string; properties: Record<string, unknown> }
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}>()
|
}>()
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import os from "os"
|
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"
|
import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"
|
||||||
import { NamedError } from "@opencode-ai/util/error"
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
@@ -109,9 +108,7 @@ export namespace ConfigPaths {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "")
|
let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "")
|
||||||
if (filePath.startsWith("~/")) {
|
filePath = Filesystem.expandHome(filePath)
|
||||||
filePath = path.join(os.homedir(), filePath.slice(2))
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
|
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
|
||||||
const fileContent = (
|
const fileContent = (
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export namespace Workspace {
|
|||||||
await parseSSE(res.body, stop, (event) => {
|
await parseSSE(res.body, stop, (event) => {
|
||||||
GlobalBus.emit("event", {
|
GlobalBus.emit("event", {
|
||||||
directory: space.id,
|
directory: space.id,
|
||||||
payload: event,
|
payload: event as { type: string; properties: Record<string, unknown> },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
// Wait 250ms and retry if SSE connection fails
|
// Wait 250ms and retry if SSE connection fails
|
||||||
|
|||||||
@@ -95,9 +95,7 @@ export namespace InstructionPrompt {
|
|||||||
if (config.instructions) {
|
if (config.instructions) {
|
||||||
for (let instruction of config.instructions) {
|
for (let instruction of config.instructions) {
|
||||||
if (instruction.startsWith("https://") || instruction.startsWith("http://")) continue
|
if (instruction.startsWith("https://") || instruction.startsWith("http://")) continue
|
||||||
if (instruction.startsWith("~/")) {
|
instruction = Filesystem.expandHome(instruction)
|
||||||
instruction = path.join(os.homedir(), instruction.slice(2))
|
|
||||||
}
|
|
||||||
const matches = path.isAbsolute(instruction)
|
const matches = path.isAbsolute(instruction)
|
||||||
? await Glob.scan(path.basename(instruction), {
|
? await Glob.scan(path.basename(instruction), {
|
||||||
cwd: path.dirname(instruction),
|
cwd: path.dirname(instruction),
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ export namespace SessionPrompt {
|
|||||||
if (seen.has(name)) return
|
if (seen.has(name)) return
|
||||||
seen.add(name)
|
seen.add(name)
|
||||||
const filepath = name.startsWith("~/")
|
const filepath = name.startsWith("~/")
|
||||||
? path.join(os.homedir(), name.slice(2))
|
? Filesystem.expandHome(name)
|
||||||
: path.resolve(Instance.worktree, name)
|
: path.resolve(Instance.worktree, name)
|
||||||
|
|
||||||
const stats = await fs.stat(filepath).catch(() => undefined)
|
const stats = await fs.stat(filepath).catch(() => undefined)
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import os from "os"
|
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Effect, Layer, ServiceMap } from "effect"
|
import { Effect, Fiber, Layer, ServiceMap } from "effect"
|
||||||
import { NamedError } from "@opencode-ai/util/error"
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
import type { Agent } from "@/agent/agent"
|
import type { Agent } from "@/agent/agent"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
|
import { AppFileSystem } from "@/filesystem"
|
||||||
import { InstanceContext } from "@/effect/instance-context"
|
import { InstanceContext } from "@/effect/instance-context"
|
||||||
import { runPromiseInstance } from "@/effect/runtime"
|
import { runPromiseInstance } from "@/effect/runtime"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
@@ -14,7 +14,6 @@ import { PermissionNext } from "@/permission"
|
|||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
import { ConfigMarkdown } from "../config/markdown"
|
import { ConfigMarkdown } from "../config/markdown"
|
||||||
import { Glob } from "../util/glob"
|
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { Discovery } from "./discovery"
|
import { Discovery } from "./discovery"
|
||||||
|
|
||||||
@@ -54,11 +53,6 @@ export namespace Skill {
|
|||||||
type State = {
|
type State = {
|
||||||
skills: Record<string, Info>
|
skills: Record<string, Info>
|
||||||
dirs: Set<string>
|
dirs: Set<string>
|
||||||
task?: Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
type Cache = State & {
|
|
||||||
ensure: () => Promise<void>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
@@ -68,153 +62,172 @@ export namespace Skill {
|
|||||||
readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
|
readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
const add = async (state: State, match: string) => {
|
|
||||||
const md = await ConfigMarkdown.parse(match).catch(async (err) => {
|
|
||||||
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
|
||||||
? err.data.message
|
|
||||||
: `Failed to parse skill ${match}`
|
|
||||||
const { Session } = await import("@/session")
|
|
||||||
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
|
||||||
log.error("failed to load skill", { skill: match, err })
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!md) return
|
|
||||||
|
|
||||||
const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
|
|
||||||
if (!parsed.success) return
|
|
||||||
|
|
||||||
if (state.skills[parsed.data.name]) {
|
|
||||||
log.warn("duplicate skill name", {
|
|
||||||
name: parsed.data.name,
|
|
||||||
existing: state.skills[parsed.data.name].location,
|
|
||||||
duplicate: match,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
state.dirs.add(path.dirname(match))
|
|
||||||
state.skills[parsed.data.name] = {
|
|
||||||
name: parsed.data.name,
|
|
||||||
description: parsed.data.description,
|
|
||||||
location: match,
|
|
||||||
content: md.content,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => {
|
|
||||||
return Glob.scan(pattern, {
|
|
||||||
cwd: root,
|
|
||||||
absolute: true,
|
|
||||||
include: "file",
|
|
||||||
symlink: true,
|
|
||||||
dot: opts?.dot,
|
|
||||||
})
|
|
||||||
.then((matches) => Promise.all(matches.map((match) => add(state, match))))
|
|
||||||
.catch((error) => {
|
|
||||||
if (!opts?.scope) throw error
|
|
||||||
log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Migrate to Effect
|
|
||||||
const create = (instance: InstanceContext.Shape, discovery: Discovery.Interface): Cache => {
|
|
||||||
const state: State = {
|
|
||||||
skills: {},
|
|
||||||
dirs: new Set<string>(),
|
|
||||||
}
|
|
||||||
|
|
||||||
const load = async () => {
|
|
||||||
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
|
|
||||||
for (const dir of EXTERNAL_DIRS) {
|
|
||||||
const root = path.join(Global.Path.home, dir)
|
|
||||||
if (!(await Filesystem.isDir(root))) continue
|
|
||||||
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
|
|
||||||
}
|
|
||||||
|
|
||||||
for await (const root of Filesystem.up({
|
|
||||||
targets: EXTERNAL_DIRS,
|
|
||||||
start: instance.directory,
|
|
||||||
stop: instance.project.worktree,
|
|
||||||
})) {
|
|
||||||
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const dir of await Config.directories()) {
|
|
||||||
await scan(state, dir, OPENCODE_SKILL_PATTERN)
|
|
||||||
}
|
|
||||||
|
|
||||||
const cfg = await Config.get()
|
|
||||||
for (const item of cfg.skills?.paths ?? []) {
|
|
||||||
const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
|
|
||||||
const dir = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded)
|
|
||||||
if (!(await Filesystem.isDir(dir))) {
|
|
||||||
log.warn("skill path not found", { path: dir })
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
await scan(state, dir, SKILL_PATTERN)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const url of cfg.skills?.urls ?? []) {
|
|
||||||
for (const dir of await Effect.runPromise(discovery.pull(url))) {
|
|
||||||
state.dirs.add(dir)
|
|
||||||
await scan(state, dir, SKILL_PATTERN)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("init", { count: Object.keys(state.skills).length })
|
|
||||||
}
|
|
||||||
|
|
||||||
const ensure = () => {
|
|
||||||
if (state.task) return state.task
|
|
||||||
state.task = load().catch((err) => {
|
|
||||||
state.task = undefined
|
|
||||||
throw err
|
|
||||||
})
|
|
||||||
return state.task
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...state, ensure }
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
|
||||||
|
|
||||||
export const layer: Layer.Layer<Service, never, InstanceContext | Discovery.Service> = Layer.effect(
|
export const layer: Layer.Layer<Service, never, InstanceContext | Discovery.Service | AppFileSystem.Service> =
|
||||||
Service,
|
Layer.effect(
|
||||||
Effect.gen(function* () {
|
Service,
|
||||||
const instance = yield* InstanceContext
|
Effect.gen(function* () {
|
||||||
const discovery = yield* Discovery.Service
|
const instance = yield* InstanceContext
|
||||||
const state = create(instance, discovery)
|
const discovery = yield* Discovery.Service
|
||||||
|
const fs = yield* AppFileSystem.Service
|
||||||
|
|
||||||
const get = Effect.fn("Skill.get")(function* (name: string) {
|
const state: State = {
|
||||||
yield* Effect.promise(() => state.ensure())
|
skills: {},
|
||||||
return state.skills[name]
|
dirs: new Set<string>(),
|
||||||
})
|
}
|
||||||
|
|
||||||
const all = Effect.fn("Skill.all")(function* () {
|
const add = Effect.fn("Skill.add")(function* (match: string) {
|
||||||
yield* Effect.promise(() => state.ensure())
|
const md = yield* Effect.tryPromise(() => ConfigMarkdown.parse(match)).pipe(
|
||||||
return Object.values(state.skills)
|
Effect.catch((err) =>
|
||||||
})
|
Effect.gen(function* () {
|
||||||
|
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||||
|
? err.data.message
|
||||||
|
: `Failed to parse skill ${match}`
|
||||||
|
const { Session } = yield* Effect.promise(() => import("@/session"))
|
||||||
|
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||||
|
log.error("failed to load skill", { skill: match, err })
|
||||||
|
return undefined
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const dirs = Effect.fn("Skill.dirs")(function* () {
|
if (!md) return
|
||||||
yield* Effect.promise(() => state.ensure())
|
|
||||||
return Array.from(state.dirs)
|
|
||||||
})
|
|
||||||
|
|
||||||
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
|
const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
|
||||||
yield* Effect.promise(() => state.ensure())
|
if (!parsed.success) return
|
||||||
const list = Object.values(state.skills).toSorted((a, b) => a.name.localeCompare(b.name))
|
|
||||||
if (!agent) return list
|
|
||||||
return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny")
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ get, all, dirs, available })
|
if (state.skills[parsed.data.name]) {
|
||||||
}),
|
log.warn("duplicate skill name", {
|
||||||
)
|
name: parsed.data.name,
|
||||||
|
existing: state.skills[parsed.data.name].location,
|
||||||
|
duplicate: match,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
state.dirs.add(path.dirname(match))
|
||||||
|
state.skills[parsed.data.name] = {
|
||||||
|
name: parsed.data.name,
|
||||||
|
description: parsed.data.description,
|
||||||
|
location: match,
|
||||||
|
content: md.content,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const scan = Effect.fn("Skill.scan")(function* (
|
||||||
|
root: string,
|
||||||
|
pattern: string,
|
||||||
|
opts?: { dot?: boolean; scope?: string },
|
||||||
|
) {
|
||||||
|
const matches = yield* fs
|
||||||
|
.glob(pattern, {
|
||||||
|
cwd: root,
|
||||||
|
absolute: true,
|
||||||
|
include: "file",
|
||||||
|
symlink: true,
|
||||||
|
dot: opts?.dot,
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
Effect.catch((error) => {
|
||||||
|
if (!opts?.scope) return Effect.fail(error)
|
||||||
|
return Effect.sync(() => {
|
||||||
|
log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
|
||||||
|
return [] as string[]
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* Effect.forEach(matches, (match) => add(match), { concurrency: "unbounded" })
|
||||||
|
})
|
||||||
|
|
||||||
|
const load = Effect.fn("Skill.load")(function* () {
|
||||||
|
// Phase 1: External dirs (global)
|
||||||
|
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
|
||||||
|
for (const dir of EXTERNAL_DIRS) {
|
||||||
|
const root = path.join(Global.Path.home, dir)
|
||||||
|
if (!(yield* fs.isDir(root).pipe(Effect.orDie))) continue
|
||||||
|
yield* scan(root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: External dirs (project, walk up)
|
||||||
|
const roots = yield* fs
|
||||||
|
.up({
|
||||||
|
targets: EXTERNAL_DIRS,
|
||||||
|
start: instance.directory,
|
||||||
|
stop: instance.project.worktree,
|
||||||
|
})
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
|
||||||
|
yield* Effect.forEach(
|
||||||
|
roots,
|
||||||
|
(root) => scan(root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" }),
|
||||||
|
{ concurrency: "unbounded" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3: Config directories
|
||||||
|
const dirs = yield* Effect.promise(() => Config.directories())
|
||||||
|
yield* Effect.forEach(dirs, (dir) => scan(dir, OPENCODE_SKILL_PATTERN), { concurrency: "unbounded" })
|
||||||
|
|
||||||
|
// Phase 4: Custom paths
|
||||||
|
const cfg = yield* Effect.promise(() => Config.get())
|
||||||
|
for (const item of cfg.skills?.paths ?? []) {
|
||||||
|
const expanded = Filesystem.expandHome(item)
|
||||||
|
const dir = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded)
|
||||||
|
if (!(yield* fs.isDir(dir).pipe(Effect.orDie))) {
|
||||||
|
log.warn("skill path not found", { path: dir })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* scan(dir, SKILL_PATTERN)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 5: Remote URLs
|
||||||
|
for (const url of cfg.skills?.urls ?? []) {
|
||||||
|
const pullDirs = yield* discovery.pull(url)
|
||||||
|
for (const dir of pullDirs) {
|
||||||
|
state.dirs.add(dir)
|
||||||
|
yield* scan(dir, SKILL_PATTERN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("init", { count: Object.keys(state.skills).length })
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadFiber = yield* load().pipe(
|
||||||
|
Effect.catchCause((cause) => Effect.sync(() => log.error("init failed", { cause }))),
|
||||||
|
Effect.forkScoped,
|
||||||
|
)
|
||||||
|
|
||||||
|
const get = Effect.fn("Skill.get")(function* (name: string) {
|
||||||
|
yield* Fiber.join(loadFiber)
|
||||||
|
return state.skills[name]
|
||||||
|
})
|
||||||
|
|
||||||
|
const all = Effect.fn("Skill.all")(function* () {
|
||||||
|
yield* Fiber.join(loadFiber)
|
||||||
|
return Object.values(state.skills)
|
||||||
|
})
|
||||||
|
|
||||||
|
const dirs = Effect.fn("Skill.dirs")(function* () {
|
||||||
|
yield* Fiber.join(loadFiber)
|
||||||
|
return Array.from(state.dirs)
|
||||||
|
})
|
||||||
|
|
||||||
|
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
|
||||||
|
yield* Fiber.join(loadFiber)
|
||||||
|
const list = Object.values(state.skills).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||||
|
if (!agent) return list
|
||||||
|
return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny")
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ get, all, dirs, available })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
export const defaultLayer: Layer.Layer<Service, never, InstanceContext> = layer.pipe(
|
export const defaultLayer: Layer.Layer<Service, never, InstanceContext> = layer.pipe(
|
||||||
Layer.provide(Discovery.defaultLayer),
|
Layer.provide(Discovery.defaultLayer),
|
||||||
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
export async function get(name: string) {
|
export async function get(name: string) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { chmod, mkdir, readFile, writeFile } from "fs/promises"
|
|||||||
import { createWriteStream, existsSync, statSync } from "fs"
|
import { createWriteStream, existsSync, statSync } from "fs"
|
||||||
import { lookup } from "mime-types"
|
import { lookup } from "mime-types"
|
||||||
import { realpathSync } from "fs"
|
import { realpathSync } from "fs"
|
||||||
|
import os from "os"
|
||||||
import { dirname, join, relative, resolve as pathResolve } from "path"
|
import { dirname, join, relative, resolve as pathResolve } from "path"
|
||||||
import { Readable } from "stream"
|
import { Readable } from "stream"
|
||||||
import { pipeline } from "stream/promises"
|
import { pipeline } from "stream/promises"
|
||||||
@@ -95,6 +96,10 @@ export namespace Filesystem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function expandHome(p: string): string {
|
||||||
|
return p.startsWith("~/") ? join(os.homedir(), p.slice(2)) : p
|
||||||
|
}
|
||||||
|
|
||||||
export function mimeType(p: string): string {
|
export function mimeType(p: string): string {
|
||||||
return lookup(p) || "application/octet-stream"
|
return lookup(p) || "application/octet-stream"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import path from "path"
|
|||||||
import { Deferred, Effect, Option } from "effect"
|
import { Deferred, Effect, Option } from "effect"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import { watcherConfigLayer, withServices } from "../fixture/instance"
|
import { watcherConfigLayer, withServices } from "../fixture/instance"
|
||||||
|
import { Bus } from "../../src/bus"
|
||||||
import { FileWatcher } from "../../src/file/watcher"
|
import { FileWatcher } from "../../src/file/watcher"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { GlobalBus } from "../../src/bus/global"
|
|
||||||
|
|
||||||
// Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows)
|
// Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows)
|
||||||
const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
|
const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
|
||||||
@@ -16,7 +16,6 @@ const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? desc
|
|||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } }
|
|
||||||
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
|
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
|
||||||
|
|
||||||
/** Run `body` with a live FileWatcher service. */
|
/** Run `body` with a live FileWatcher service. */
|
||||||
@@ -36,22 +35,17 @@ function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
|
|||||||
function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) {
|
function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) {
|
||||||
let done = false
|
let done = false
|
||||||
|
|
||||||
function on(evt: BusUpdate) {
|
const unsub = Bus.subscribe(FileWatcher.Event.Updated, (evt) => {
|
||||||
if (done) return
|
if (done) return
|
||||||
if (evt.directory !== directory) return
|
if (!check(evt.properties)) return
|
||||||
if (evt.payload.type !== FileWatcher.Event.Updated.type) return
|
hit(evt.properties)
|
||||||
if (!check(evt.payload.properties)) return
|
})
|
||||||
hit(evt.payload.properties)
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanup() {
|
return () => {
|
||||||
if (done) return
|
if (done) return
|
||||||
done = true
|
done = true
|
||||||
GlobalBus.off("event", on)
|
unsub()
|
||||||
}
|
}
|
||||||
|
|
||||||
GlobalBus.on("event", on)
|
|
||||||
return cleanup
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function wait(directory: string, check: (evt: WatcherEvent) => boolean) {
|
function wait(directory: string, check: (evt: WatcherEvent) => boolean) {
|
||||||
|
|||||||
Reference in New Issue
Block a user