From 62ef2a220723a6d6cb050e523fcdfaa974dafdda Mon Sep 17 00:00:00 2001 From: Dax Date: Sat, 25 Apr 2026 10:59:17 -0400 Subject: refactor: rename shared package to core (#24309) --- bun.lock | 60 +-- packages/app/package.json | 2 +- .../app/src/components/dialog-edit-project.tsx | 2 +- packages/app/src/components/dialog-fork.tsx | 2 +- .../app/src/components/dialog-select-directory.tsx | 2 +- packages/app/src/components/dialog-select-file.tsx | 4 +- .../components/prompt-input/build-request-parts.ts | 2 +- .../src/components/prompt-input/context-items.tsx | 2 +- .../src/components/prompt-input/slash-popover.tsx | 2 +- .../app/src/components/prompt-input/submit.test.ts | 2 +- packages/app/src/components/prompt-input/submit.ts | 4 +- .../src/components/session/session-context-tab.tsx | 4 +- .../app/src/components/session/session-header.tsx | 2 +- .../src/components/session/session-new-view.tsx | 2 +- .../components/session/session-sortable-tab.tsx | 2 +- packages/app/src/context/file.tsx | 2 +- packages/app/src/context/global-sync.tsx | 2 +- packages/app/src/context/global-sync/bootstrap.ts | 4 +- .../app/src/context/global-sync/event-reducer.ts | 2 +- packages/app/src/context/local.tsx | 2 +- packages/app/src/context/notification.tsx | 4 +- .../src/context/permission-auto-respond.test.ts | 2 +- .../app/src/context/permission-auto-respond.ts | 2 +- packages/app/src/context/prompt.tsx | 2 +- packages/app/src/context/sync.tsx | 4 +- packages/app/src/pages/directory-layout.tsx | 2 +- packages/app/src/pages/home.tsx | 2 +- packages/app/src/pages/layout.tsx | 8 +- packages/app/src/pages/layout/helpers.ts | 2 +- packages/app/src/pages/layout/sidebar-items.tsx | 2 +- packages/app/src/pages/layout/sidebar-project.tsx | 2 +- .../app/src/pages/layout/sidebar-workspace.tsx | 4 +- packages/app/src/pages/session.tsx | 2 +- packages/app/src/pages/session/file-tabs.tsx | 2 +- .../app/src/pages/session/message-timeline.tsx | 4 +- .../app/src/pages/session/use-session-commands.tsx | 2 +- packages/app/src/utils/base64.ts | 2 +- packages/app/src/utils/persist.ts | 2 +- packages/core/package.json | 39 ++ packages/core/src/filesystem.ts | 236 ++++++++++++ packages/core/src/global.ts | 42 ++ packages/core/src/types.d.ts | 46 +++ packages/core/src/util/array.ts | 10 + packages/core/src/util/binary.ts | 41 ++ packages/core/src/util/effect-flock.ts | 283 ++++++++++++++ packages/core/src/util/encode.ts | 51 +++ packages/core/src/util/error.ts | 60 +++ packages/core/src/util/flock.ts | 358 +++++++++++++++++ packages/core/src/util/fn.ts | 11 + packages/core/src/util/glob.ts | 34 ++ packages/core/src/util/hash.ts | 7 + packages/core/src/util/identifier.ts | 48 +++ packages/core/src/util/iife.ts | 3 + packages/core/src/util/lazy.ts | 11 + packages/core/src/util/module.ts | 10 + packages/core/src/util/path.ts | 37 ++ packages/core/src/util/retry.ts | 42 ++ packages/core/src/util/slug.ts | 74 ++++ packages/core/sst-env.d.ts | 10 + packages/core/test/filesystem/filesystem.test.ts | 338 ++++++++++++++++ packages/core/test/fixture/effect-flock-worker.ts | 63 +++ packages/core/test/fixture/flock-worker.ts | 72 ++++ packages/core/test/lib/effect.ts | 53 +++ packages/core/test/util/effect-flock.test.ts | 389 +++++++++++++++++++ packages/core/test/util/flock.test.ts | 426 +++++++++++++++++++++ packages/core/tsconfig.json | 14 + packages/enterprise/package.json | 2 +- packages/enterprise/src/core/share.ts | 4 +- packages/enterprise/src/core/storage.ts | 2 +- packages/enterprise/src/routes/share/[shareID].tsx | 6 +- packages/enterprise/test/core/share.test.ts | 2 +- packages/opencode/package.json | 2 +- packages/opencode/src/acp/agent.ts | 2 +- packages/opencode/src/auth/index.ts | 2 +- packages/opencode/src/cli/cmd/tui/config/tui.ts | 2 +- packages/opencode/src/cli/cmd/tui/context/kv.tsx | 2 +- packages/opencode/src/cli/cmd/tui/context/sync.tsx | 2 +- .../opencode/src/cli/cmd/tui/context/theme.tsx | 2 +- .../opencode/src/cli/cmd/tui/plugin/runtime.ts | 2 +- packages/opencode/src/cli/error.ts | 2 +- packages/opencode/src/cli/ui.ts | 2 +- packages/opencode/src/config/agent.ts | 4 +- packages/opencode/src/config/command.ts | 4 +- packages/opencode/src/config/config.ts | 6 +- packages/opencode/src/config/error.ts | 2 +- packages/opencode/src/config/markdown.ts | 2 +- packages/opencode/src/config/paths.ts | 2 +- packages/opencode/src/config/plugin.ts | 2 +- packages/opencode/src/control-plane/workspace.ts | 2 +- packages/opencode/src/effect/app-runtime.ts | 2 +- packages/opencode/src/file/ignore.ts | 2 +- packages/opencode/src/file/index.ts | 2 +- packages/opencode/src/file/ripgrep.ts | 2 +- packages/opencode/src/global/index.ts | 2 +- packages/opencode/src/ide/index.ts | 2 +- packages/opencode/src/index.ts | 2 +- packages/opencode/src/lsp/client.ts | 2 +- packages/opencode/src/lsp/lsp.ts | 2 +- packages/opencode/src/lsp/server.ts | 2 +- packages/opencode/src/mcp/auth.ts | 2 +- packages/opencode/src/mcp/index.ts | 4 +- packages/opencode/src/npm/index.ts | 6 +- packages/opencode/src/plugin/index.ts | 2 +- packages/opencode/src/plugin/install.ts | 2 +- packages/opencode/src/plugin/meta.ts | 2 +- packages/opencode/src/project/instance.ts | 2 +- packages/opencode/src/project/project.ts | 2 +- packages/opencode/src/project/vcs.ts | 2 +- packages/opencode/src/provider/models.ts | 4 +- packages/opencode/src/provider/provider.ts | 4 +- packages/opencode/src/pty/index.ts | 2 +- packages/opencode/src/server/middleware.ts | 2 +- .../src/server/routes/instance/middleware.ts | 2 +- .../opencode/src/server/routes/instance/session.ts | 2 +- packages/opencode/src/session/instruction.ts | 2 +- packages/opencode/src/session/message-v2.ts | 2 +- packages/opencode/src/session/prompt.ts | 4 +- packages/opencode/src/session/retry.ts | 2 +- packages/opencode/src/session/session.ts | 2 +- packages/opencode/src/skill/discovery.ts | 2 +- packages/opencode/src/skill/index.ts | 6 +- packages/opencode/src/snapshot/index.ts | 4 +- packages/opencode/src/storage/db.ts | 2 +- packages/opencode/src/storage/json-migration.ts | 2 +- packages/opencode/src/storage/storage.ts | 4 +- packages/opencode/src/tool/apply_patch.ts | 2 +- packages/opencode/src/tool/bash.ts | 2 +- packages/opencode/src/tool/edit.ts | 2 +- packages/opencode/src/tool/external-directory.ts | 2 +- packages/opencode/src/tool/glob.ts | 2 +- packages/opencode/src/tool/grep.ts | 2 +- packages/opencode/src/tool/lsp.ts | 2 +- packages/opencode/src/tool/read.ts | 2 +- packages/opencode/src/tool/registry.ts | 4 +- packages/opencode/src/tool/truncate.ts | 2 +- packages/opencode/src/tool/write.ts | 2 +- packages/opencode/src/util/bom.ts | 2 +- packages/opencode/src/util/filesystem.ts | 2 +- packages/opencode/src/util/log.ts | 2 +- packages/opencode/src/worktree/index.ts | 6 +- packages/opencode/test/config/config.test.ts | 6 +- .../opencode/test/filesystem/filesystem.test.ts | 2 +- packages/opencode/test/fixture/flock-worker.ts | 2 +- packages/opencode/test/npm.test.ts | 6 +- packages/opencode/test/project/project.test.ts | 2 +- packages/opencode/test/session/prompt.test.ts | 4 +- packages/opencode/test/session/retry.test.ts | 2 +- .../test/session/snapshot-tool-race.test.ts | 2 +- packages/opencode/test/storage/storage.test.ts | 2 +- packages/opencode/test/tool/apply_patch.test.ts | 2 +- packages/opencode/test/tool/bash.test.ts | 2 +- packages/opencode/test/tool/edit.test.ts | 2 +- packages/opencode/test/tool/glob.test.ts | 2 +- packages/opencode/test/tool/grep.test.ts | 2 +- packages/opencode/test/tool/lsp.test.ts | 2 +- packages/opencode/test/tool/read.test.ts | 2 +- packages/opencode/test/tool/write.test.ts | 2 +- packages/opencode/test/util/glob.test.ts | 2 +- packages/opencode/test/util/module.test.ts | 2 +- packages/shared/package.json | 39 -- packages/shared/src/filesystem.ts | 236 ------------ packages/shared/src/global.ts | 42 -- packages/shared/src/types.d.ts | 46 --- packages/shared/src/util/array.ts | 10 - packages/shared/src/util/binary.ts | 41 -- packages/shared/src/util/effect-flock.ts | 283 -------------- packages/shared/src/util/encode.ts | 51 --- packages/shared/src/util/error.ts | 60 --- packages/shared/src/util/flock.ts | 358 ----------------- packages/shared/src/util/fn.ts | 11 - packages/shared/src/util/glob.ts | 34 -- packages/shared/src/util/hash.ts | 7 - packages/shared/src/util/identifier.ts | 48 --- packages/shared/src/util/iife.ts | 3 - packages/shared/src/util/lazy.ts | 11 - packages/shared/src/util/module.ts | 10 - packages/shared/src/util/path.ts | 37 -- packages/shared/src/util/retry.ts | 42 -- packages/shared/src/util/slug.ts | 74 ---- packages/shared/sst-env.d.ts | 10 - packages/shared/test/filesystem/filesystem.test.ts | 338 ---------------- .../shared/test/fixture/effect-flock-worker.ts | 63 --- packages/shared/test/fixture/flock-worker.ts | 72 ---- packages/shared/test/lib/effect.ts | 53 --- packages/shared/test/util/effect-flock.test.ts | 389 ------------------- packages/shared/test/util/flock.test.ts | 426 --------------------- packages/shared/tsconfig.json | 14 - packages/ui/package.json | 2 +- packages/ui/src/components/file.tsx | 2 +- packages/ui/src/components/line-comment.tsx | 2 +- packages/ui/src/components/markdown.tsx | 2 +- packages/ui/src/components/message-part.tsx | 4 +- packages/ui/src/components/session-review.tsx | 4 +- packages/ui/src/components/session-turn.tsx | 4 +- 194 files changed, 3014 insertions(+), 3014 deletions(-) create mode 100644 packages/core/package.json create mode 100644 packages/core/src/filesystem.ts create mode 100644 packages/core/src/global.ts create mode 100644 packages/core/src/types.d.ts create mode 100644 packages/core/src/util/array.ts create mode 100644 packages/core/src/util/binary.ts create mode 100644 packages/core/src/util/effect-flock.ts create mode 100644 packages/core/src/util/encode.ts create mode 100644 packages/core/src/util/error.ts create mode 100644 packages/core/src/util/flock.ts create mode 100644 packages/core/src/util/fn.ts create mode 100644 packages/core/src/util/glob.ts create mode 100644 packages/core/src/util/hash.ts create mode 100644 packages/core/src/util/identifier.ts create mode 100644 packages/core/src/util/iife.ts create mode 100644 packages/core/src/util/lazy.ts create mode 100644 packages/core/src/util/module.ts create mode 100644 packages/core/src/util/path.ts create mode 100644 packages/core/src/util/retry.ts create mode 100644 packages/core/src/util/slug.ts create mode 100644 packages/core/sst-env.d.ts create mode 100644 packages/core/test/filesystem/filesystem.test.ts create mode 100644 packages/core/test/fixture/effect-flock-worker.ts create mode 100644 packages/core/test/fixture/flock-worker.ts create mode 100644 packages/core/test/lib/effect.ts create mode 100644 packages/core/test/util/effect-flock.test.ts create mode 100644 packages/core/test/util/flock.test.ts create mode 100644 packages/core/tsconfig.json delete mode 100644 packages/shared/package.json delete mode 100644 packages/shared/src/filesystem.ts delete mode 100644 packages/shared/src/global.ts delete mode 100644 packages/shared/src/types.d.ts delete mode 100644 packages/shared/src/util/array.ts delete mode 100644 packages/shared/src/util/binary.ts delete mode 100644 packages/shared/src/util/effect-flock.ts delete mode 100644 packages/shared/src/util/encode.ts delete mode 100644 packages/shared/src/util/error.ts delete mode 100644 packages/shared/src/util/flock.ts delete mode 100644 packages/shared/src/util/fn.ts delete mode 100644 packages/shared/src/util/glob.ts delete mode 100644 packages/shared/src/util/hash.ts delete mode 100644 packages/shared/src/util/identifier.ts delete mode 100644 packages/shared/src/util/iife.ts delete mode 100644 packages/shared/src/util/lazy.ts delete mode 100644 packages/shared/src/util/module.ts delete mode 100644 packages/shared/src/util/path.ts delete mode 100644 packages/shared/src/util/retry.ts delete mode 100644 packages/shared/src/util/slug.ts delete mode 100644 packages/shared/sst-env.d.ts delete mode 100644 packages/shared/test/filesystem/filesystem.test.ts delete mode 100644 packages/shared/test/fixture/effect-flock-worker.ts delete mode 100644 packages/shared/test/fixture/flock-worker.ts delete mode 100644 packages/shared/test/lib/effect.ts delete mode 100644 packages/shared/test/util/effect-flock.test.ts delete mode 100644 packages/shared/test/util/flock.test.ts delete mode 100644 packages/shared/tsconfig.json diff --git a/bun.lock b/bun.lock index ff5f6bb7d..e28376682 100644 --- a/bun.lock +++ b/bun.lock @@ -32,8 +32,8 @@ "version": "1.14.25", "dependencies": { "@kobalte/core": "catalog:", + "@opencode-ai/core": "workspace:*", "@opencode-ai/sdk": "workspace:*", - "@opencode-ai/shared": "workspace:*", "@opencode-ai/ui": "workspace:*", "@shikijs/transformers": "3.9.2", "@solid-primitives/active-element": "2.1.3", @@ -190,6 +190,30 @@ "cloudflare": "5.2.0", }, }, + "packages/core": { + "name": "@opencode-ai/core", + "version": "1.14.25", + "bin": { + "opencode": "./bin/opencode", + }, + "dependencies": { + "@effect/platform-node": "catalog:", + "@npmcli/arborist": "catalog:", + "effect": "catalog:", + "glob": "13.0.5", + "mime-types": "3.0.2", + "minimatch": "10.2.5", + "semver": "catalog:", + "xdg-basedir": "5.1.0", + "zod": "catalog:", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@types/npmcli__arborist": "6.3.3", + "@types/semver": "catalog:", + }, + }, "packages/desktop": { "name": "@opencode-ai/desktop", "version": "1.14.25", @@ -271,7 +295,7 @@ "name": "@opencode-ai/enterprise", "version": "1.14.25", "dependencies": { - "@opencode-ai/shared": "workspace:*", + "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", "@pierre/diffs": "catalog:", "@solidjs/meta": "catalog:", @@ -426,8 +450,8 @@ "@babel/core": "7.28.4", "@effect/language-service": "0.84.2", "@octokit/webhooks-types": "7.6.1", + "@opencode-ai/core": "workspace:*", "@opencode-ai/script": "workspace:*", - "@opencode-ai/shared": "workspace:*", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", @@ -508,30 +532,6 @@ "typescript": "catalog:", }, }, - "packages/shared": { - "name": "@opencode-ai/shared", - "version": "1.14.25", - "bin": { - "opencode": "./bin/opencode", - }, - "dependencies": { - "@effect/platform-node": "catalog:", - "@npmcli/arborist": "catalog:", - "effect": "catalog:", - "glob": "13.0.5", - "mime-types": "3.0.2", - "minimatch": "10.2.5", - "semver": "catalog:", - "xdg-basedir": "5.1.0", - "zod": "catalog:", - }, - "devDependencies": { - "@tsconfig/bun": "catalog:", - "@types/bun": "catalog:", - "@types/npmcli__arborist": "6.3.3", - "@types/semver": "catalog:", - }, - }, "packages/slack": { "name": "@opencode-ai/slack", "version": "1.14.25", @@ -572,8 +572,8 @@ "version": "1.14.25", "dependencies": { "@kobalte/core": "catalog:", + "@opencode-ai/core": "workspace:*", "@opencode-ai/sdk": "workspace:*", - "@opencode-ai/shared": "workspace:*", "@pierre/diffs": "catalog:", "@shikijs/transformers": "3.9.2", "@solid-primitives/bounds": "0.1.3", @@ -1554,6 +1554,8 @@ "@opencode-ai/console-resource": ["@opencode-ai/console-resource@workspace:packages/console/resource"], + "@opencode-ai/core": ["@opencode-ai/core@workspace:packages/core"], + "@opencode-ai/desktop": ["@opencode-ai/desktop@workspace:packages/desktop"], "@opencode-ai/desktop-electron": ["@opencode-ai/desktop-electron@workspace:packages/desktop-electron"], @@ -1568,8 +1570,6 @@ "@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"], - "@opencode-ai/shared": ["@opencode-ai/shared@workspace:packages/shared"], - "@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"], "@opencode-ai/storybook": ["@opencode-ai/storybook@workspace:packages/storybook"], diff --git a/packages/app/package.json b/packages/app/package.json index 7f65da4d9..f9d8150ba 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -42,7 +42,7 @@ "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", - "@opencode-ai/shared": "workspace:*", + "@opencode-ai/core": "workspace:*", "@shikijs/transformers": "3.9.2", "@solid-primitives/active-element": "2.1.3", "@solid-primitives/audio": "1.4.2", diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index 8eb12daf5..b4b69246c 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -9,7 +9,7 @@ import { createStore } from "solid-js/store" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" import { type LocalProject, getAvatarColors } from "@/context/layout" -import { getFilename } from "@opencode-ai/shared/util/path" +import { getFilename } from "@opencode-ai/core/util/path" import { Avatar } from "@opencode-ai/ui/avatar" import { useLanguage } from "@/context/language" import { getProjectAvatarSource } from "@/pages/layout/sidebar-items" diff --git a/packages/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx index 710618c30..3618a0581 100644 --- a/packages/app/src/components/dialog-fork.tsx +++ b/packages/app/src/components/dialog-fork.tsx @@ -9,7 +9,7 @@ import { List } from "@opencode-ai/ui/list" import { showToast } from "@opencode-ai/ui/toast" import { extractPromptFromParts } from "@/utils/prompt" import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client" -import { base64Encode } from "@opencode-ai/shared/util/encode" +import { base64Encode } from "@opencode-ai/core/util/encode" import { useLanguage } from "@/context/language" interface ForkableMessage { diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index 903cb1915..005d28709 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -3,7 +3,7 @@ import { Dialog } from "@opencode-ai/ui/dialog" import { FileIcon } from "@opencode-ai/ui/file-icon" import { List } from "@opencode-ai/ui/list" import type { ListRef } from "@opencode-ai/ui/list" -import { getDirectory, getFilename } from "@opencode-ai/shared/util/path" +import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import fuzzysort from "fuzzysort" import { createMemo, createResource, createSignal } from "solid-js" import { useGlobalSDK } from "@/context/global-sdk" diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 186906f92..63a321e46 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -4,8 +4,8 @@ import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" import { Keybind } from "@opencode-ai/ui/keybind" import { List } from "@opencode-ai/ui/list" -import { base64Encode } from "@opencode-ai/shared/util/encode" -import { getDirectory, getFilename } from "@opencode-ai/shared/util/path" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { useNavigate } from "@solidjs/router" import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js" import { formatKeybind, useCommand, type CommandOption } from "@/context/command" diff --git a/packages/app/src/components/prompt-input/build-request-parts.ts b/packages/app/src/components/prompt-input/build-request-parts.ts index c268af35e..98771aedd 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.ts @@ -1,4 +1,4 @@ -import { getFilename } from "@opencode-ai/shared/util/path" +import { getFilename } from "@opencode-ai/core/util/path" import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client" import type { FileSelection } from "@/context/file" import { encodeFilePath } from "@/context/file/path" diff --git a/packages/app/src/components/prompt-input/context-items.tsx b/packages/app/src/components/prompt-input/context-items.tsx index 9f20f1c04..95289f989 100644 --- a/packages/app/src/components/prompt-input/context-items.tsx +++ b/packages/app/src/components/prompt-input/context-items.tsx @@ -2,7 +2,7 @@ import { Component, For, Show } from "solid-js" import { FileIcon } from "@opencode-ai/ui/file-icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip } from "@opencode-ai/ui/tooltip" -import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/shared/util/path" +import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/core/util/path" import type { ContextItem } from "@/context/prompt" type PromptContextItem = ContextItem & { key: string } diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx index 0c8c95923..d8c4bd035 100644 --- a/packages/app/src/components/prompt-input/slash-popover.tsx +++ b/packages/app/src/components/prompt-input/slash-popover.tsx @@ -1,7 +1,7 @@ import { Component, For, Match, Show, Switch } from "solid-js" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" -import { getDirectory, getFilename } from "@opencode-ai/shared/util/path" +import { getDirectory, getFilename } from "@opencode-ai/core/util/path" export type AtOption = | { type: "agent"; name: string; display: string } diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index cf9949723..83b6212dc 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -74,7 +74,7 @@ beforeAll(async () => { showToast: () => 0, })) - mock.module("@opencode-ai/shared/util/encode", () => ({ + mock.module("@opencode-ai/core/util/encode", () => ({ base64Encode: (value: string) => value, })) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 6805f619c..05f0a3ed2 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -1,7 +1,7 @@ import type { Message, Session } from "@opencode-ai/sdk/v2/client" import { showToast } from "@opencode-ai/ui/toast" -import { base64Encode } from "@opencode-ai/shared/util/encode" -import { Binary } from "@opencode-ai/shared/util/binary" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { Binary } from "@opencode-ai/core/util/binary" import { useNavigate, useParams } from "@solidjs/router" import { batch, type Accessor } from "solid-js" import type { FileSelection } from "@/context/file" diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index abf4c9334..43741bd3f 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -1,8 +1,8 @@ import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js" import type { JSX } from "solid-js" import { useSync } from "@/context/sync" -import { checksum } from "@opencode-ai/shared/util/encode" -import { findLast } from "@opencode-ai/shared/util/array" +import { checksum } from "@opencode-ai/core/util/encode" +import { findLast } from "@opencode-ai/core/util/array" import { same } from "@/utils/same" import { Icon } from "@opencode-ai/ui/icon" import { Accordion } from "@opencode-ai/ui/accordion" diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 021e5be67..3d4f58dee 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -7,7 +7,7 @@ import { Keybind } from "@opencode-ai/ui/keybind" import { Spinner } from "@opencode-ai/ui/spinner" import { showToast } from "@opencode-ai/ui/toast" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" -import { getFilename } from "@opencode-ai/shared/util/path" +import { getFilename } from "@opencode-ai/core/util/path" import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js" import { createStore } from "solid-js/store" import { Portal } from "solid-js/web" diff --git a/packages/app/src/components/session/session-new-view.tsx b/packages/app/src/components/session/session-new-view.tsx index d2cac28fc..36c1eb42c 100644 --- a/packages/app/src/components/session/session-new-view.tsx +++ b/packages/app/src/components/session/session-new-view.tsx @@ -5,7 +5,7 @@ import { useSDK } from "@/context/sdk" import { useLanguage } from "@/context/language" import { Icon } from "@opencode-ai/ui/icon" import { Mark } from "@opencode-ai/ui/logo" -import { getDirectory, getFilename } from "@opencode-ai/shared/util/path" +import { getDirectory, getFilename } from "@opencode-ai/core/util/path" const MAIN_WORKTREE = "main" const CREATE_WORKTREE = "create" diff --git a/packages/app/src/components/session/session-sortable-tab.tsx b/packages/app/src/components/session/session-sortable-tab.tsx index fb2275c44..f04228ca6 100644 --- a/packages/app/src/components/session/session-sortable-tab.tsx +++ b/packages/app/src/components/session/session-sortable-tab.tsx @@ -5,7 +5,7 @@ import { FileIcon } from "@opencode-ai/ui/file-icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tabs } from "@opencode-ai/ui/tabs" -import { getFilename } from "@opencode-ai/shared/util/path" +import { getFilename } from "@opencode-ai/core/util/path" import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" import { useCommand } from "@/context/command" diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index 8998731a6..0298e3416 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -3,7 +3,7 @@ import { createStore, produce, reconcile } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { showToast } from "@opencode-ai/ui/toast" import { useParams } from "@solidjs/router" -import { getFilename } from "@opencode-ai/shared/util/path" +import { getFilename } from "@opencode-ai/core/util/path" import { useSDK } from "./sdk" import { useSync } from "./sync" import { useLanguage } from "@/context/language" diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index b742667d7..86496bad5 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -8,7 +8,7 @@ import type { Todo, } from "@opencode-ai/sdk/v2/client" import { showToast } from "@opencode-ai/ui/toast" -import { getFilename } from "@opencode-ai/shared/util/path" +import { getFilename } from "@opencode-ai/core/util/path" import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import { useLanguage } from "@/context/language" diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index be789a5e5..66f4a3b15 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -11,8 +11,8 @@ import type { Todo, } from "@opencode-ai/sdk/v2/client" import { showToast } from "@opencode-ai/ui/toast" -import { getFilename } from "@opencode-ai/shared/util/path" -import { retry } from "@opencode-ai/shared/util/retry" +import { getFilename } from "@opencode-ai/core/util/path" +import { retry } from "@opencode-ai/core/util/retry" import { batch } from "solid-js" import { reconcile, type SetStoreFunction, type Store } from "solid-js/store" import type { State, VcsCache } from "./types" diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 82408fdfe..5f43c341b 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -1,4 +1,4 @@ -import { Binary } from "@opencode-ai/shared/util/binary" +import { Binary } from "@opencode-ai/core/util/binary" import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import type { Message, diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index 0b0972ee6..2db0f9b04 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -1,5 +1,5 @@ import { createSimpleContext } from "@opencode-ai/ui/context" -import { base64Encode } from "@opencode-ai/shared/util/encode" +import { base64Encode } from "@opencode-ai/core/util/encode" import { useParams } from "@solidjs/router" import { batch, createEffect, createMemo } from "solid-js" import { createStore } from "solid-js/store" diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 251b67b06..c926dc1d9 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -7,8 +7,8 @@ import { useGlobalSync } from "./global-sync" import { usePlatform } from "@/context/platform" import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" -import { Binary } from "@opencode-ai/shared/util/binary" -import { base64Encode } from "@opencode-ai/shared/util/encode" +import { Binary } from "@opencode-ai/core/util/binary" +import { base64Encode } from "@opencode-ai/core/util/encode" import { decode64 } from "@/utils/base64" import { EventSessionError } from "@opencode-ai/sdk/v2" import { Persist, persisted } from "@/utils/persist" diff --git a/packages/app/src/context/permission-auto-respond.test.ts b/packages/app/src/context/permission-auto-respond.test.ts index 2f8ca6265..002ae94e5 100644 --- a/packages/app/src/context/permission-auto-respond.test.ts +++ b/packages/app/src/context/permission-auto-respond.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" -import { base64Encode } from "@opencode-ai/shared/util/encode" +import { base64Encode } from "@opencode-ai/core/util/encode" import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond" const session = (input: { id: string; parentID?: string }) => diff --git a/packages/app/src/context/permission-auto-respond.ts b/packages/app/src/context/permission-auto-respond.ts index 2ebca3434..58ab75c57 100644 --- a/packages/app/src/context/permission-auto-respond.ts +++ b/packages/app/src/context/permission-auto-respond.ts @@ -1,4 +1,4 @@ -import { base64Encode } from "@opencode-ai/shared/util/encode" +import { base64Encode } from "@opencode-ai/core/util/encode" export function acceptKey(sessionID: string, directory?: string) { if (!directory) return sessionID diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 15af57b35..dffb79831 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -1,5 +1,5 @@ import { createSimpleContext } from "@opencode-ai/ui/context" -import { checksum } from "@opencode-ai/shared/util/encode" +import { checksum } from "@opencode-ai/core/util/encode" import { useParams } from "@solidjs/router" import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js" import { createStore, type SetStoreFunction } from "solid-js/store" diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 29b7fe68c..34b597b6b 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -1,7 +1,7 @@ import { batch, createMemo } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" -import { Binary } from "@opencode-ai/shared/util/binary" -import { retry } from "@opencode-ai/shared/util/retry" +import { Binary } from "@opencode-ai/core/util/binary" +import { retry } from "@opencode-ai/core/util/retry" import { createSimpleContext } from "@opencode-ai/ui/context" import { clearSessionPrefetch, diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 36514f56c..90ce3c1a5 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -1,6 +1,6 @@ import { DataProvider } from "@opencode-ai/ui/context" import { showToast } from "@opencode-ai/ui/toast" -import { base64Encode } from "@opencode-ai/shared/util/encode" +import { base64Encode } from "@opencode-ai/core/util/encode" import { useLocation, useNavigate, useParams } from "@solidjs/router" import { createEffect, createMemo, createResource, type ParentProps, Show } from "solid-js" import { useLanguage } from "@/context/language" diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 46cacdf62..2df69ee92 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -3,7 +3,7 @@ import { Button } from "@opencode-ai/ui/button" import { Logo } from "@opencode-ai/ui/logo" import { useLayout } from "@/context/layout" import { useNavigate } from "@solidjs/router" -import { base64Encode } from "@opencode-ai/shared/util/encode" +import { base64Encode } from "@opencode-ai/core/util/encode" import { Icon } from "@opencode-ai/ui/icon" import { usePlatform } from "@/context/platform" import { DateTime } from "luxon" diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index ac5cf104a..d9ce87a02 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -17,7 +17,7 @@ import { useLocation, useNavigate, useParams } from "@solidjs/router" import { useLayout, LocalProject } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" import { Persist, persisted } from "@/utils/persist" -import { base64Encode } from "@opencode-ai/shared/util/encode" +import { base64Encode } from "@opencode-ai/core/util/encode" import { decode64 } from "@/utils/base64" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Button } from "@opencode-ai/ui/button" @@ -25,7 +25,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip } from "@opencode-ai/ui/tooltip" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Dialog } from "@opencode-ai/ui/dialog" -import { getFilename } from "@opencode-ai/shared/util/path" +import { getFilename } from "@opencode-ai/core/util/path" import { Session, type Message } from "@opencode-ai/sdk/v2/client" import { usePlatform } from "@/context/platform" import { useSettings } from "@/context/settings" @@ -48,8 +48,8 @@ import { } from "@/context/global-sync/session-prefetch" import { useNotification } from "@/context/notification" import { usePermission } from "@/context/permission" -import { Binary } from "@opencode-ai/shared/util/binary" -import { retry } from "@opencode-ai/shared/util/retry" +import { Binary } from "@opencode-ai/core/util/binary" +import { retry } from "@opencode-ai/core/util/retry" import { playSoundById } from "@/utils/sound" import { createAim } from "@/utils/aim" import { setNavigate } from "@/utils/notification-click" diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index 32b94c9cb..4bc5254d9 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -1,4 +1,4 @@ -import { getFilename } from "@opencode-ai/shared/util/path" +import { getFilename } from "@opencode-ai/core/util/path" import { type Session } from "@opencode-ai/sdk/v2/client" type SessionStore = { diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 9a9a1d7fc..d9fd4d6a8 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -4,7 +4,7 @@ import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Spinner } from "@opencode-ai/ui/spinner" import { Tooltip } from "@opencode-ai/ui/tooltip" -import { getFilename } from "@opencode-ai/shared/util/path" +import { getFilename } from "@opencode-ai/core/util/path" import { A, useParams } from "@solidjs/router" import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js" import { useGlobalSync } from "@/context/global-sync" diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx index 076e1ef88..2ba20092c 100644 --- a/packages/app/src/pages/layout/sidebar-project.tsx +++ b/packages/app/src/pages/layout/sidebar-project.tsx @@ -1,6 +1,6 @@ import { createMemo, For, Show, type Accessor, type JSX } from "solid-js" import { createStore } from "solid-js/store" -import { base64Encode } from "@opencode-ai/shared/util/encode" +import { base64Encode } from "@opencode-ai/core/util/encode" import { Button } from "@opencode-ai/ui/button" import { ContextMenu } from "@opencode-ai/ui/context-menu" import { HoverCard } from "@opencode-ai/ui/hover-card" diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index cbb570106..0a3fc7f41 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -3,8 +3,8 @@ import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "so import { createStore } from "solid-js/store" import { createSortable } from "@thisbeyond/solid-dnd" import { createMediaQuery } from "@solid-primitives/media" -import { base64Encode } from "@opencode-ai/shared/util/encode" -import { getFilename } from "@opencode-ai/shared/util/path" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { getFilename } from "@opencode-ai/core/util/path" import { Button } from "@opencode-ai/ui/button" import { Collapsible } from "@opencode-ai/ui/collapsible" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 4ae973b85..1345e355e 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -28,7 +28,7 @@ import { createAutoScroll } from "@opencode-ai/ui/hooks" import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" import { Button } from "@opencode-ai/ui/button" import { showToast } from "@opencode-ai/ui/toast" -import { checksum } from "@opencode-ai/shared/util/encode" +import { checksum } from "@opencode-ai/core/util/encode" import { useSearchParams } from "@solidjs/router" import { NewSessionView, SessionHeader } from "@/components/session" import { useComments } from "@/context/comments" diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index 37bffcd2f..65b076d7c 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -6,7 +6,7 @@ import type { FileSearchHandle } from "@opencode-ai/ui/file" import { useFileComponent } from "@opencode-ai/ui/context/file" import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" import { createLineCommentController } from "@opencode-ai/ui/line-comment-annotations" -import { sampledChecksum } from "@opencode-ai/shared/util/encode" +import { sampledChecksum } from "@opencode-ai/core/util/encode" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { IconButton } from "@opencode-ai/ui/icon-button" import { Tabs } from "@opencode-ai/ui/tabs" diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 592ca774e..8bbaafb4e 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -15,8 +15,8 @@ import { ScrollView } from "@opencode-ai/ui/scroll-view" import { TextField } from "@opencode-ai/ui/text-field" import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2" import { showToast } from "@opencode-ai/ui/toast" -import { Binary } from "@opencode-ai/shared/util/binary" -import { getFilename } from "@opencode-ai/shared/util/path" +import { Binary } from "@opencode-ai/core/util/binary" +import { getFilename } from "@opencode-ai/core/util/path" import { Popover as KobaltePopover } from "@kobalte/core/popover" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" import { SessionContextUsage } from "@/components/session-context-usage" diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index d649aeb0c..922299bec 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -14,7 +14,7 @@ import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" import { showToast } from "@opencode-ai/ui/toast" -import { findLast } from "@opencode-ai/shared/util/array" +import { findLast } from "@opencode-ai/core/util/array" import { createSessionTabs } from "@/pages/session/helpers" import { extractPromptFromParts } from "@/utils/prompt" import { UserMessage } from "@opencode-ai/sdk/v2" diff --git a/packages/app/src/utils/base64.ts b/packages/app/src/utils/base64.ts index f60dff2b6..34b904051 100644 --- a/packages/app/src/utils/base64.ts +++ b/packages/app/src/utils/base64.ts @@ -1,4 +1,4 @@ -import { base64Decode } from "@opencode-ai/shared/util/encode" +import { base64Decode } from "@opencode-ai/core/util/encode" export function decode64(value: string | undefined) { if (value === undefined) return diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts index 0cac30cb1..024552727 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -1,6 +1,6 @@ import { Platform, usePlatform } from "@/context/platform" import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage" -import { checksum } from "@opencode-ai/shared/util/encode" +import { checksum } from "@opencode-ai/core/util/encode" import { createResource, type Accessor } from "solid-js" import type { SetStoreFunction, Store } from "solid-js/store" diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 000000000..48d44ccf3 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "version": "1.14.25", + "name": "@opencode-ai/core", + "type": "module", + "license": "MIT", + "private": true, + "scripts": { + "test": "bun test", + "typecheck": "tsgo --noEmit" + }, + "bin": { + "opencode": "./bin/opencode" + }, + "exports": { + "./*": "./src/*.ts" + }, + "imports": {}, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/semver": "catalog:", + "@types/bun": "catalog:", + "@types/npmcli__arborist": "6.3.3" + }, + "dependencies": { + "@effect/platform-node": "catalog:", + "@npmcli/arborist": "catalog:", + "effect": "catalog:", + "glob": "13.0.5", + "mime-types": "3.0.2", + "minimatch": "10.2.5", + "semver": "catalog:", + "xdg-basedir": "5.1.0", + "zod": "catalog:" + }, + "overrides": { + "drizzle-orm": "catalog:" + } +} diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts new file mode 100644 index 000000000..44346be8f --- /dev/null +++ b/packages/core/src/filesystem.ts @@ -0,0 +1,236 @@ +import { NodeFileSystem } from "@effect/platform-node" +import { dirname, join, relative, resolve as pathResolve } from "path" +import { realpathSync } from "fs" +import * as NFS from "fs/promises" +import { lookup } from "mime-types" +import { Effect, FileSystem, Layer, Schema, Context } from "effect" +import type { PlatformError } from "effect/PlatformError" +import { Glob } from "./util/glob" + +export namespace AppFileSystem { + export class FileSystemError extends Schema.TaggedErrorClass()("FileSystemError", { + method: Schema.String, + cause: Schema.optional(Schema.Defect), + }) {} + + export type Error = PlatformError | FileSystemError + + export interface DirEntry { + readonly name: string + readonly type: "file" | "directory" | "symlink" | "other" + } + + export interface Interface extends FileSystem.FileSystem { + readonly isDir: (path: string) => Effect.Effect + readonly isFile: (path: string) => Effect.Effect + readonly existsSafe: (path: string) => Effect.Effect + readonly readJson: (path: string) => Effect.Effect + readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect + readonly ensureDir: (path: string) => Effect.Effect + readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect + readonly readDirectoryEntries: (path: string) => Effect.Effect + readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect + readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect + readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect + readonly glob: (pattern: string, options?: Glob.Options) => Effect.Effect + readonly globMatch: (pattern: string, filepath: string) => boolean + } + + export class Service extends Context.Service()("@opencode/FileSystem") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + + const existsSafe = Effect.fn("FileSystem.existsSafe")(function* (path: string) { + return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false)) + }) + + const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) { + const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void)) + return info?.type === "Directory" + }) + + const isFile = Effect.fn("FileSystem.isFile")(function* (path: string) { + const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void)) + return info?.type === "File" + }) + + const readDirectoryEntries = Effect.fn("FileSystem.readDirectoryEntries")(function* (dirPath: string) { + return yield* Effect.tryPromise({ + try: async () => { + const entries = await NFS.readdir(dirPath, { withFileTypes: true }) + return entries.map( + (e): DirEntry => ({ + name: e.name, + type: e.isDirectory() ? "directory" : e.isSymbolicLink() ? "symlink" : e.isFile() ? "file" : "other", + }), + ) + }, + catch: (cause) => new FileSystemError({ method: "readDirectoryEntries", cause }), + }) + }) + + const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) { + const text = yield* fs.readFileString(path) + return JSON.parse(text) + }) + + const writeJson = Effect.fn("FileSystem.writeJson")(function* (path: string, data: unknown, mode?: number) { + const content = JSON.stringify(data, null, 2) + yield* fs.writeFileString(path, content) + if (mode) yield* fs.chmod(path, mode) + }) + + const ensureDir = Effect.fn("FileSystem.ensureDir")(function* (path: string) { + yield* fs.makeDirectory(path, { recursive: true }) + }) + + const writeWithDirs = Effect.fn("FileSystem.writeWithDirs")(function* ( + path: string, + content: string | Uint8Array, + mode?: number, + ) { + const write = typeof content === "string" ? fs.writeFileString(path, content) : fs.writeFile(path, content) + + yield* write.pipe( + Effect.catchIf( + (e) => e.reason._tag === "NotFound", + () => + Effect.gen(function* () { + yield* fs.makeDirectory(dirname(path), { recursive: true }) + yield* write + }), + ), + ) + if (mode) yield* fs.chmod(path, mode) + }) + + const glob = Effect.fn("FileSystem.glob")(function* (pattern: string, options?: Glob.Options) { + return yield* Effect.tryPromise({ + try: () => Glob.scan(pattern, options), + catch: (cause) => new FileSystemError({ method: "glob", cause }), + }) + }) + + const findUp = Effect.fn("FileSystem.findUp")(function* (target: string, start: string, stop?: string) { + const result: string[] = [] + let current = start + while (true) { + const search = join(current, target) + if (yield* fs.exists(search)) result.push(search) + if (stop === current) break + const parent = dirname(current) + if (parent === current) break + current = parent + } + return result + }) + + const up = Effect.fn("FileSystem.up")(function* (options: { targets: string[]; start: string; stop?: string }) { + const result: string[] = [] + let current = options.start + while (true) { + for (const target of options.targets) { + const search = join(current, target) + if (yield* fs.exists(search)) result.push(search) + } + if (options.stop === current) break + const parent = dirname(current) + if (parent === current) break + current = parent + } + return result + }) + + const globUp = Effect.fn("FileSystem.globUp")(function* (pattern: string, start: string, stop?: string) { + const result: string[] = [] + let current = start + while (true) { + const matches = yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true }).pipe( + Effect.catch(() => Effect.succeed([] as string[])), + ) + result.push(...matches) + if (stop === current) break + const parent = dirname(current) + if (parent === current) break + current = parent + } + return result + }) + + return Service.of({ + ...fs, + existsSafe, + isDir, + isFile, + readDirectoryEntries, + readJson, + writeJson, + ensureDir, + writeWithDirs, + findUp, + up, + globUp, + glob, + globMatch: Glob.match, + }) + }), + ) + + export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer)) + + // Pure helpers that don't need Effect (path manipulation, sync operations) + export function mimeType(p: string): string { + return lookup(p) || "application/octet-stream" + } + + export function normalizePath(p: string): string { + if (process.platform !== "win32") return p + const resolved = pathResolve(windowsPath(p)) + try { + return realpathSync.native(resolved) + } catch { + return resolved + } + } + + export function normalizePathPattern(p: string): string { + if (process.platform !== "win32") return p + if (p === "*") return p + const match = p.match(/^(.*)[\\/]\*$/) + if (!match) return normalizePath(p) + const dir = /^[A-Za-z]:$/.test(match[1]) ? match[1] + "\\" : match[1] + return join(normalizePath(dir), "*") + } + + export function resolve(p: string): string { + const resolved = pathResolve(windowsPath(p)) + try { + return normalizePath(realpathSync(resolved)) + } catch (e: any) { + if (e?.code === "ENOENT") return normalizePath(resolved) + throw e + } + } + + export function windowsPath(p: string): string { + if (process.platform !== "win32") return p + return p + .replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`) + .replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) + .replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) + .replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) + } + + export function overlaps(a: string, b: string) { + const relA = relative(a, b) + const relB = relative(b, a) + return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..") + } + + export function contains(parent: string, child: string) { + return !relative(parent, child).startsWith("..") + } +} diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts new file mode 100644 index 000000000..538cc091b --- /dev/null +++ b/packages/core/src/global.ts @@ -0,0 +1,42 @@ +import path from "path" +import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir" +import os from "os" +import { Context, Effect, Layer } from "effect" + +export namespace Global { + export class Service extends Context.Service()("@opencode/Global") {} + + export interface Interface { + readonly home: string + readonly data: string + readonly cache: string + readonly config: string + readonly state: string + readonly bin: string + readonly log: string + } + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const app = "opencode" + const home = process.env.OPENCODE_TEST_HOME ?? os.homedir() + const data = path.join(xdgData!, app) + const cache = path.join(xdgCache!, app) + const cfg = path.join(xdgConfig!, app) + const state = path.join(xdgState!, app) + const bin = path.join(cache, "bin") + const log = path.join(data, "log") + + return Service.of({ + home, + data, + cache, + config: cfg, + state, + bin, + log, + }) + }), + ) +} diff --git a/packages/core/src/types.d.ts b/packages/core/src/types.d.ts new file mode 100644 index 000000000..60e1639ad --- /dev/null +++ b/packages/core/src/types.d.ts @@ -0,0 +1,46 @@ +declare module "@npmcli/arborist" { + export interface ArboristOptions { + path: string + binLinks?: boolean + progress?: boolean + savePrefix?: string + ignoreScripts?: boolean + [key: string]: unknown + } + + export interface ArboristNode { + name: string + path: string + } + + export interface ArboristEdge { + to?: ArboristNode + } + + export interface ArboristTree { + edgesOut: Map + } + + export interface ReifyOptions { + add?: string[] + save?: boolean + saveType?: "prod" | "dev" | "optional" | "peer" + [key: string]: unknown + } + + export class Arborist { + constructor(options: ArboristOptions) + loadVirtual(): Promise + reify(options?: ReifyOptions): Promise + } +} + +declare var Bun: + | { + file(path: string): { + text(): Promise + json(): Promise + } + write(path: string, content: string | Uint8Array): Promise + } + | undefined diff --git a/packages/core/src/util/array.ts b/packages/core/src/util/array.ts new file mode 100644 index 000000000..1fb8ac69e --- /dev/null +++ b/packages/core/src/util/array.ts @@ -0,0 +1,10 @@ +export function findLast( + items: readonly T[], + predicate: (item: T, index: number, items: readonly T[]) => boolean, +): T | undefined { + for (let i = items.length - 1; i >= 0; i -= 1) { + const item = items[i] + if (predicate(item, i, items)) return item + } + return undefined +} diff --git a/packages/core/src/util/binary.ts b/packages/core/src/util/binary.ts new file mode 100644 index 000000000..3d8f61851 --- /dev/null +++ b/packages/core/src/util/binary.ts @@ -0,0 +1,41 @@ +export namespace Binary { + export function search(array: T[], id: string, compare: (item: T) => string): { found: boolean; index: number } { + let left = 0 + let right = array.length - 1 + + while (left <= right) { + const mid = Math.floor((left + right) / 2) + const midId = compare(array[mid]) + + if (midId === id) { + return { found: true, index: mid } + } else if (midId < id) { + left = mid + 1 + } else { + right = mid - 1 + } + } + + return { found: false, index: left } + } + + export function insert(array: T[], item: T, compare: (item: T) => string): T[] { + const id = compare(item) + let left = 0 + let right = array.length + + while (left < right) { + const mid = Math.floor((left + right) / 2) + const midId = compare(array[mid]) + + if (midId < id) { + left = mid + 1 + } else { + right = mid + } + } + + array.splice(left, 0, item) + return array + } +} diff --git a/packages/core/src/util/effect-flock.ts b/packages/core/src/util/effect-flock.ts new file mode 100644 index 000000000..16bcf091b --- /dev/null +++ b/packages/core/src/util/effect-flock.ts @@ -0,0 +1,283 @@ +import path from "path" +import os from "os" +import { randomUUID } from "crypto" +import { Context, Effect, Function, Layer, Option, Schedule, Schema } from "effect" +import type { FileSystem, Scope } from "effect" +import type { PlatformError } from "effect/PlatformError" +import { AppFileSystem } from "../filesystem" +import { Global } from "../global" +import { Hash } from "./hash" + +export namespace EffectFlock { + // --------------------------------------------------------------------------- + // Errors + // --------------------------------------------------------------------------- + + export class LockTimeoutError extends Schema.TaggedErrorClass()("LockTimeoutError", { + key: Schema.String, + }) {} + + export class LockCompromisedError extends Schema.TaggedErrorClass()("LockCompromisedError", { + detail: Schema.String, + }) {} + + class ReleaseError extends Schema.TaggedErrorClass()("ReleaseError", { + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }) { + override get message() { + return this.detail + } + } + + /** Internal: signals "lock is held, retry later". Never leaks to callers. */ + class NotAcquired extends Schema.TaggedErrorClass()("NotAcquired", {}) {} + + export type LockError = LockTimeoutError | LockCompromisedError + + // --------------------------------------------------------------------------- + // Timing (baked in — no caller ever overrides these) + // --------------------------------------------------------------------------- + + const STALE_MS = 60_000 + const TIMEOUT_MS = 5 * 60_000 + const BASE_DELAY_MS = 100 + const MAX_DELAY_MS = 2_000 + const HEARTBEAT_MS = Math.max(100, Math.floor(STALE_MS / 3)) + + const retrySchedule = Schedule.exponential(BASE_DELAY_MS, 1.7).pipe( + Schedule.either(Schedule.spaced(MAX_DELAY_MS)), + Schedule.jittered, + Schedule.while((meta) => meta.elapsed < TIMEOUT_MS), + ) + + // --------------------------------------------------------------------------- + // Lock metadata schema + // --------------------------------------------------------------------------- + + const LockMetaJson = Schema.fromJsonString( + Schema.Struct({ + token: Schema.String, + pid: Schema.Number, + hostname: Schema.String, + createdAt: Schema.String, + }), + ) + + const decodeMeta = Schema.decodeUnknownSync(LockMetaJson) + const encodeMeta = Schema.encodeSync(LockMetaJson) + + // --------------------------------------------------------------------------- + // Service + // --------------------------------------------------------------------------- + + export interface Interface { + readonly acquire: (key: string, dir?: string) => Effect.Effect + readonly withLock: { + (key: string, dir?: string): (body: Effect.Effect) => Effect.Effect + (body: Effect.Effect, key: string, dir?: string): Effect.Effect + } + } + + export class Service extends Context.Service()("EffectFlock") {} + + // --------------------------------------------------------------------------- + // Layer + // --------------------------------------------------------------------------- + + function wall() { + return performance.timeOrigin + performance.now() + } + + const mtimeMs = (info: FileSystem.File.Info) => Option.getOrElse(info.mtime, () => new Date(0)).getTime() + + const isPathGone = (e: PlatformError) => e.reason._tag === "NotFound" || e.reason._tag === "Unknown" + + export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const global = yield* Global.Service + const fs = yield* AppFileSystem.Service + const lockRoot = path.join(global.state, "locks") + const hostname = os.hostname() + const ensuredDirs = new Set() + + // -- helpers (close over fs) -- + + const safeStat = (file: string) => + fs.stat(file).pipe( + Effect.catchIf(isPathGone, () => Effect.void), + Effect.orDie, + ) + + const forceRemove = (target: string) => fs.remove(target, { recursive: true }).pipe(Effect.ignore) + + /** Atomic mkdir — returns true if created, false if already exists, dies on other errors. */ + const atomicMkdir = (dir: string) => + fs.makeDirectory(dir, { mode: 0o700 }).pipe( + Effect.as(true), + Effect.catchIf( + (e) => e.reason._tag === "AlreadyExists", + () => Effect.succeed(false), + ), + Effect.orDie, + ) + + /** Write with exclusive create — compromised error if file already exists. */ + const exclusiveWrite = (filePath: string, content: string, lockDir: string, detail: string) => + fs.writeFileString(filePath, content, { flag: "wx" }).pipe( + Effect.catch(() => + Effect.gen(function* () { + yield* forceRemove(lockDir) + return yield* new LockCompromisedError({ detail }) + }), + ), + ) + + const cleanStaleBreaker = Effect.fnUntraced(function* (breakerPath: string) { + const bs = yield* safeStat(breakerPath) + if (bs && wall() - mtimeMs(bs) > STALE_MS) yield* forceRemove(breakerPath) + return false + }) + + const ensureDir = Effect.fnUntraced(function* (dir: string) { + if (ensuredDirs.has(dir)) return + yield* fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie) + ensuredDirs.add(dir) + }) + + const isStale = Effect.fnUntraced(function* (lockDir: string, heartbeatPath: string, metaPath: string) { + const now = wall() + + const hb = yield* safeStat(heartbeatPath) + if (hb) return now - mtimeMs(hb) > STALE_MS + + const meta = yield* safeStat(metaPath) + if (meta) return now - mtimeMs(meta) > STALE_MS + + const dir = yield* safeStat(lockDir) + if (!dir) return false + + return now - mtimeMs(dir) > STALE_MS + }) + + // -- single lock attempt -- + + type Handle = { token: string; metaPath: string; heartbeatPath: string; lockDir: string } + + const tryAcquireLockDir = (lockDir: string, key: string) => + Effect.gen(function* () { + const token = randomUUID() + const metaPath = path.join(lockDir, "meta.json") + const heartbeatPath = path.join(lockDir, "heartbeat") + + // Atomic mkdir — the POSIX lock primitive + const created = yield* atomicMkdir(lockDir) + + if (!created) { + if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return yield* new NotAcquired() + + // Stale — race for breaker ownership + const breakerPath = lockDir + ".breaker" + + const claimed = yield* fs.makeDirectory(breakerPath, { mode: 0o700 }).pipe( + Effect.as(true), + Effect.catchIf( + (e) => e.reason._tag === "AlreadyExists", + () => cleanStaleBreaker(breakerPath), + ), + Effect.catchIf(isPathGone, () => Effect.succeed(false)), + Effect.orDie, + ) + + if (!claimed) return yield* new NotAcquired() + + // We own the breaker — double-check staleness, nuke, recreate + const recreated = yield* Effect.gen(function* () { + if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return false + yield* forceRemove(lockDir) + return yield* atomicMkdir(lockDir) + }).pipe(Effect.ensuring(forceRemove(breakerPath))) + + if (!recreated) return yield* new NotAcquired() + } + + // We own the lock dir — write heartbeat + meta with exclusive create + yield* exclusiveWrite(heartbeatPath, "", lockDir, "heartbeat already existed") + + const metaJson = encodeMeta({ token, pid: process.pid, hostname, createdAt: new Date().toISOString() }) + yield* exclusiveWrite(metaPath, metaJson, lockDir, "meta.json already existed") + + return { token, metaPath, heartbeatPath, lockDir } satisfies Handle + }).pipe( + Effect.withSpan("EffectFlock.tryAcquire", { + attributes: { key }, + }), + ) + + // -- retry wrapper (preserves Handle type) -- + + const acquireHandle = (lockfile: string, key: string): Effect.Effect => + tryAcquireLockDir(lockfile, key).pipe( + Effect.retry({ + while: (err) => err._tag === "NotAcquired", + schedule: retrySchedule, + }), + Effect.catchTag("NotAcquired", () => Effect.fail(new LockTimeoutError({ key }))), + ) + + // -- release -- + + const release = (handle: Handle) => + Effect.gen(function* () { + const raw = yield* fs.readFileString(handle.metaPath).pipe( + Effect.catch((err) => { + if (isPathGone(err)) return Effect.die(new ReleaseError({ detail: "metadata missing" })) + return Effect.die(err) + }), + ) + + const parsed = yield* Effect.try({ + try: () => decodeMeta(raw), + catch: (cause) => new ReleaseError({ detail: "metadata invalid", cause }), + }).pipe(Effect.orDie) + + if (parsed.token !== handle.token) return yield* Effect.die(new ReleaseError({ detail: "token mismatch" })) + + yield* forceRemove(handle.lockDir) + }) + + // -- build service -- + + const acquire = Effect.fn("EffectFlock.acquire")(function* (key: string, dir?: string) { + const lockDir = dir ?? lockRoot + yield* ensureDir(lockDir) + + const lockfile = path.join(lockDir, Hash.fast(key) + ".lock") + + // acquireRelease: acquire is uninterruptible, release is guaranteed + const handle = yield* Effect.acquireRelease(acquireHandle(lockfile, key), (handle) => release(handle)) + + // Heartbeat fiber — scoped, so it's interrupted before release runs + yield* fs + .utimes(handle.heartbeatPath, new Date(), new Date()) + .pipe(Effect.ignore, Effect.repeat(Schedule.spaced(HEARTBEAT_MS)), Effect.forkScoped) + }) + + const withLock: Interface["withLock"] = Function.dual( + (args) => Effect.isEffect(args[0]), + (body: Effect.Effect, key: string, dir?: string): Effect.Effect => + Effect.scoped( + Effect.gen(function* () { + yield* acquire(key, dir) + return yield* body + }), + ), + ) + + return Service.of({ acquire, withLock }) + }), + ) + + export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.layer)) +} diff --git a/packages/core/src/util/encode.ts b/packages/core/src/util/encode.ts new file mode 100644 index 000000000..e4c6e70ac --- /dev/null +++ b/packages/core/src/util/encode.ts @@ -0,0 +1,51 @@ +export function base64Encode(value: string) { + const bytes = new TextEncoder().encode(value) + const binary = Array.from(bytes, (b) => String.fromCharCode(b)).join("") + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "") +} + +export function base64Decode(value: string) { + const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/")) + const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0)) + return new TextDecoder().decode(bytes) +} + +export async function hash(content: string, algorithm = "SHA-256"): Promise { + const encoder = new TextEncoder() + const data = encoder.encode(content) + const hashBuffer = await crypto.subtle.digest(algorithm, data) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("") + return hashHex +} + +export function checksum(content: string): string | undefined { + if (!content) return undefined + let hash = 0x811c9dc5 + for (let i = 0; i < content.length; i++) { + hash ^= content.charCodeAt(i) + hash = Math.imul(hash, 0x01000193) + } + return (hash >>> 0).toString(36) +} + +export function sampledChecksum(content: string, limit = 500_000): string | undefined { + if (!content) return undefined + if (content.length <= limit) return checksum(content) + + const size = 4096 + const points = [ + 0, + Math.floor(content.length * 0.25), + Math.floor(content.length * 0.5), + Math.floor(content.length * 0.75), + content.length - size, + ] + const hashes = points + .map((point) => { + const start = Math.max(0, Math.min(content.length - size, point - Math.floor(size / 2))) + return checksum(content.slice(start, start + size)) ?? "" + }) + .join(":") + return `${content.length}:${hashes}` +} diff --git a/packages/core/src/util/error.ts b/packages/core/src/util/error.ts new file mode 100644 index 000000000..9d3b7c661 --- /dev/null +++ b/packages/core/src/util/error.ts @@ -0,0 +1,60 @@ +import z from "zod" + +export abstract class NamedError extends Error { + abstract schema(): z.core.$ZodType + abstract toObject(): { name: string; data: any } + + static hasName(error: unknown, name: string): boolean { + return ( + typeof error === "object" && error !== null && "name" in error && (error as Record).name === name + ) + } + + static create(name: Name, data: Data) { + const schema = z + .object({ + name: z.literal(name), + data, + }) + .meta({ + ref: name, + }) + const result = class extends NamedError { + public static readonly Schema = schema + + public override readonly name = name as Name + + constructor( + public readonly data: z.input, + options?: ErrorOptions, + ) { + super(name, options) + this.name = name + } + + static isInstance(input: any): input is InstanceType { + return typeof input === "object" && "name" in input && input.name === name + } + + schema() { + return schema + } + + toObject() { + return { + name: name, + data: this.data, + } + } + } + Object.defineProperty(result, "name", { value: name }) + return result + } + + public static readonly Unknown = NamedError.create( + "UnknownError", + z.object({ + message: z.string(), + }), + ) +} diff --git a/packages/core/src/util/flock.ts b/packages/core/src/util/flock.ts new file mode 100644 index 000000000..958bd9fd1 --- /dev/null +++ b/packages/core/src/util/flock.ts @@ -0,0 +1,358 @@ +import path from "path" +import os from "os" +import { randomBytes, randomUUID } from "crypto" +import { mkdir, readFile, rm, stat, utimes, writeFile } from "fs/promises" +import { Hash } from "./hash" +import { Effect } from "effect" + +export type FlockGlobal = { + state: string +} + +export namespace Flock { + let global: FlockGlobal | undefined + + export function setGlobal(g: FlockGlobal) { + global = g + } + + const root = () => { + if (!global) throw new Error("Flock global not set") + return path.join(global.state, "locks") + } + + // Defaults for callers that do not provide timing options. + const defaultOpts = { + staleMs: 60_000, + timeoutMs: 5 * 60_000, + baseDelayMs: 100, + maxDelayMs: 2_000, + } + + export interface WaitEvent { + key: string + attempt: number + delay: number + waited: number + } + + export type Wait = (input: WaitEvent) => void | Promise + + export interface Options { + dir?: string + signal?: AbortSignal + staleMs?: number + timeoutMs?: number + baseDelayMs?: number + maxDelayMs?: number + onWait?: Wait + } + + type Opts = { + staleMs: number + timeoutMs: number + baseDelayMs: number + maxDelayMs: number + } + + type Owned = { + acquired: true + startHeartbeat: (intervalMs?: number) => void + release: () => Promise + } + + export interface Lease { + release: () => Promise + [Symbol.asyncDispose]: () => Promise + } + + function code(err: unknown) { + if (typeof err !== "object" || err === null || !("code" in err)) return + const value = err.code + if (typeof value !== "string") return + return value + } + + function sleep(ms: number, signal?: AbortSignal) { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason ?? new Error("Aborted")) + return + } + + let timer: NodeJS.Timeout | undefined + + const done = () => { + signal?.removeEventListener("abort", abort) + resolve() + } + + const abort = () => { + if (timer) { + clearTimeout(timer) + } + signal?.removeEventListener("abort", abort) + reject(signal?.reason ?? new Error("Aborted")) + } + + signal?.addEventListener("abort", abort, { once: true }) + timer = setTimeout(done, ms) + }) + } + + function jitter(ms: number) { + const j = Math.floor(ms * 0.3) + const d = Math.floor(Math.random() * (2 * j + 1)) - j + return Math.max(0, ms + d) + } + + function mono() { + return performance.now() + } + + function wall() { + return performance.timeOrigin + mono() + } + + async function stats(file: string) { + try { + return await stat(file) + } catch (err) { + const errCode = code(err) + if (errCode === "ENOENT" || errCode === "ENOTDIR") return + throw err + } + } + + async function stale(lockDir: string, heartbeatPath: string, metaPath: string, staleMs: number) { + // Stale detection allows automatic recovery after crashed owners. + const now = wall() + const heartbeat = await stats(heartbeatPath) + if (heartbeat) { + return now - heartbeat.mtimeMs > staleMs + } + + const meta = await stats(metaPath) + if (meta) { + return now - meta.mtimeMs > staleMs + } + + const dir = await stats(lockDir) + if (!dir) { + return false + } + + return now - dir.mtimeMs > staleMs + } + + async function tryAcquireLockDir(lockDir: string, opts: Opts): Promise { + const token = randomUUID?.() ?? randomBytes(16).toString("hex") + const metaPath = path.join(lockDir, "meta.json") + const heartbeatPath = path.join(lockDir, "heartbeat") + + try { + await mkdir(lockDir, { mode: 0o700 }) + } catch (err) { + if (code(err) !== "EEXIST") { + throw err + } + + if (!(await stale(lockDir, heartbeatPath, metaPath, opts.staleMs))) { + return { acquired: false } + } + + const breakerPath = lockDir + ".breaker" + try { + await mkdir(breakerPath, { mode: 0o700 }) + } catch (claimErr) { + const errCode = code(claimErr) + if (errCode === "EEXIST") { + const breaker = await stats(breakerPath) + if (breaker && wall() - breaker.mtimeMs > opts.staleMs) { + await rm(breakerPath, { recursive: true, force: true }).catch(() => undefined) + } + return { acquired: false } + } + + if (errCode === "ENOENT" || errCode === "ENOTDIR") { + return { acquired: false } + } + + throw claimErr + } + + try { + // Breaker ownership ensures only one contender performs stale cleanup. + if (!(await stale(lockDir, heartbeatPath, metaPath, opts.staleMs))) { + return { acquired: false } + } + + await rm(lockDir, { recursive: true, force: true }) + + try { + await mkdir(lockDir, { mode: 0o700 }) + } catch (retryErr) { + const errCode = code(retryErr) + if (errCode === "EEXIST" || errCode === "ENOTEMPTY") { + return { acquired: false } + } + throw retryErr + } + } finally { + await rm(breakerPath, { recursive: true, force: true }).catch(() => undefined) + } + } + + const meta = { + token, + pid: process.pid, + hostname: os.hostname(), + createdAt: new Date().toISOString(), + } + + await writeFile(heartbeatPath, "", { flag: "wx" }).catch(async () => { + await rm(lockDir, { recursive: true, force: true }) + throw new Error("Lock acquired but heartbeat already existed (possible compromise).") + }) + + await writeFile(metaPath, JSON.stringify(meta, null, 2), { flag: "wx" }).catch(async () => { + await rm(lockDir, { recursive: true, force: true }) + throw new Error("Lock acquired but meta.json already existed (possible compromise).") + }) + + let timer: NodeJS.Timeout | undefined + + const startHeartbeat = (intervalMs = Math.max(100, Math.floor(opts.staleMs / 3))) => { + if (timer) return + // Heartbeat prevents long critical sections from being evicted as stale. + timer = setInterval(() => { + const t = new Date() + void utimes(heartbeatPath, t, t).catch(() => undefined) + }, intervalMs) + timer.unref?.() + } + + const release = async () => { + if (timer) { + clearInterval(timer) + timer = undefined + } + + const current = await readFile(metaPath, "utf8") + .then((raw) => { + const parsed = JSON.parse(raw) + if (!parsed || typeof parsed !== "object") return {} + return { + token: "token" in parsed && typeof parsed.token === "string" ? parsed.token : undefined, + } + }) + .catch((err) => { + const errCode = code(err) + if (errCode === "ENOENT" || errCode === "ENOTDIR") { + throw new Error("Refusing to release: lock is compromised (metadata missing).") + } + if (err instanceof SyntaxError) { + throw new Error("Refusing to release: lock is compromised (metadata invalid).") + } + throw err + }) + // Token check prevents deleting a lock that was re-acquired by another process. + if (current.token !== token) { + throw new Error("Refusing to release: lock token mismatch (not the owner).") + } + + await rm(lockDir, { recursive: true, force: true }) + } + + return { + acquired: true, + startHeartbeat, + release, + } + } + + async function acquireLockDir( + lockDir: string, + input: { key: string; onWait?: Wait; signal?: AbortSignal }, + opts: Opts, + ) { + const stop = mono() + opts.timeoutMs + let attempt = 0 + let waited = 0 + let delay = opts.baseDelayMs + + while (true) { + input.signal?.throwIfAborted() + + const res = await tryAcquireLockDir(lockDir, opts) + if (res.acquired) { + return res + } + + if (mono() > stop) { + throw new Error(`Timed out waiting for lock: ${input.key}`) + } + + attempt += 1 + const ms = jitter(delay) + await input.onWait?.({ + key: input.key, + attempt, + delay: ms, + waited, + }) + await sleep(ms, input.signal) + waited += ms + delay = Math.min(opts.maxDelayMs, Math.floor(delay * 1.7)) + } + } + + export async function acquire(key: string, input: Options = {}): Promise { + input.signal?.throwIfAborted() + const cfg: Opts = { + staleMs: input.staleMs ?? defaultOpts.staleMs, + timeoutMs: input.timeoutMs ?? defaultOpts.timeoutMs, + baseDelayMs: input.baseDelayMs ?? defaultOpts.baseDelayMs, + maxDelayMs: input.maxDelayMs ?? defaultOpts.maxDelayMs, + } + const dir = input.dir ?? root() + + await mkdir(dir, { recursive: true }) + const lockfile = path.join(dir, Hash.fast(key) + ".lock") + const lock = await acquireLockDir( + lockfile, + { + key, + onWait: input.onWait, + signal: input.signal, + }, + cfg, + ) + lock.startHeartbeat() + + const release = () => lock.release() + return { + release, + [Symbol.asyncDispose]() { + return release() + }, + } + } + + export async function withLock(key: string, fn: () => Promise, input: Options = {}) { + await using _ = await acquire(key, input) + input.signal?.throwIfAborted() + return await fn() + } + + export const effect = Effect.fn("Flock.effect")(function* (key: string, input: Options = {}) { + return yield* Effect.acquireRelease( + Effect.promise((signal) => Flock.acquire(key, { ...input, signal })).pipe( + Effect.withSpan("Flock.acquire", { + attributes: { key }, + }), + ), + (lock) => Effect.promise(() => lock.release()).pipe(Effect.withSpan("Flock.release")), + ).pipe(Effect.asVoid) + }) +} diff --git a/packages/core/src/util/fn.ts b/packages/core/src/util/fn.ts new file mode 100644 index 000000000..9efe4622f --- /dev/null +++ b/packages/core/src/util/fn.ts @@ -0,0 +1,11 @@ +import { z } from "zod" + +export function fn(schema: T, cb: (input: z.infer) => Result) { + const result = (input: z.infer) => { + const parsed = schema.parse(input) + return cb(parsed) + } + result.force = (input: z.infer) => cb(input) + result.schema = schema + return result +} diff --git a/packages/core/src/util/glob.ts b/packages/core/src/util/glob.ts new file mode 100644 index 000000000..febf062da --- /dev/null +++ b/packages/core/src/util/glob.ts @@ -0,0 +1,34 @@ +import { glob, globSync, type GlobOptions } from "glob" +import { minimatch } from "minimatch" + +export namespace Glob { + export interface Options { + cwd?: string + absolute?: boolean + include?: "file" | "all" + dot?: boolean + symlink?: boolean + } + + function toGlobOptions(options: Options): GlobOptions { + return { + cwd: options.cwd, + absolute: options.absolute, + dot: options.dot, + follow: options.symlink ?? false, + nodir: options.include !== "all", + } + } + + export async function scan(pattern: string, options: Options = {}): Promise { + return glob(pattern, toGlobOptions(options)) as Promise + } + + export function scanSync(pattern: string, options: Options = {}): string[] { + return globSync(pattern, toGlobOptions(options)) as string[] + } + + export function match(pattern: string, filepath: string): boolean { + return minimatch(filepath, pattern, { dot: true }) + } +} diff --git a/packages/core/src/util/hash.ts b/packages/core/src/util/hash.ts new file mode 100644 index 000000000..680e0f40b --- /dev/null +++ b/packages/core/src/util/hash.ts @@ -0,0 +1,7 @@ +import { createHash } from "crypto" + +export namespace Hash { + export function fast(input: string | Buffer): string { + return createHash("sha1").update(input).digest("hex") + } +} diff --git a/packages/core/src/util/identifier.ts b/packages/core/src/util/identifier.ts new file mode 100644 index 000000000..ba28a351b --- /dev/null +++ b/packages/core/src/util/identifier.ts @@ -0,0 +1,48 @@ +import { randomBytes } from "crypto" + +export namespace Identifier { + const LENGTH = 26 + + // State for monotonic ID generation + let lastTimestamp = 0 + let counter = 0 + + export function ascending() { + return create(false) + } + + export function descending() { + return create(true) + } + + function randomBase62(length: number): string { + const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + let result = "" + const bytes = randomBytes(length) + for (let i = 0; i < length; i++) { + result += chars[bytes[i] % 62] + } + return result + } + + export function create(descending: boolean, timestamp?: number): string { + const currentTimestamp = timestamp ?? Date.now() + + if (currentTimestamp !== lastTimestamp) { + lastTimestamp = currentTimestamp + counter = 0 + } + counter++ + + let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter) + + now = descending ? ~now : now + + const timeBytes = Buffer.alloc(6) + for (let i = 0; i < 6; i++) { + timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff)) + } + + return timeBytes.toString("hex") + randomBase62(LENGTH - 12) + } +} diff --git a/packages/core/src/util/iife.ts b/packages/core/src/util/iife.ts new file mode 100644 index 000000000..ca9ae6c10 --- /dev/null +++ b/packages/core/src/util/iife.ts @@ -0,0 +1,3 @@ +export function iife(fn: () => T) { + return fn() +} diff --git a/packages/core/src/util/lazy.ts b/packages/core/src/util/lazy.ts new file mode 100644 index 000000000..935ebe0f9 --- /dev/null +++ b/packages/core/src/util/lazy.ts @@ -0,0 +1,11 @@ +export function lazy(fn: () => T) { + let value: T | undefined + let loaded = false + + return (): T => { + if (loaded) return value as T + loaded = true + value = fn() + return value as T + } +} diff --git a/packages/core/src/util/module.ts b/packages/core/src/util/module.ts new file mode 100644 index 000000000..6ed3b23d7 --- /dev/null +++ b/packages/core/src/util/module.ts @@ -0,0 +1,10 @@ +import { createRequire } from "node:module" +import path from "node:path" + +export namespace Module { + export function resolve(id: string, dir: string) { + try { + return createRequire(path.join(dir, "package.json")).resolve(id) + } catch {} + } +} diff --git a/packages/core/src/util/path.ts b/packages/core/src/util/path.ts new file mode 100644 index 000000000..b87316358 --- /dev/null +++ b/packages/core/src/util/path.ts @@ -0,0 +1,37 @@ +export function getFilename(path: string | undefined) { + if (!path) return "" + const trimmed = path.replace(/[/\\]+$/, "") + const parts = trimmed.split(/[/\\]/) + return parts[parts.length - 1] ?? "" +} + +export function getDirectory(path: string | undefined) { + if (!path) return "" + const trimmed = path.replace(/[/\\]+$/, "") + const parts = trimmed.split(/[/\\]/) + return parts.slice(0, parts.length - 1).join("/") + "/" +} + +export function getFileExtension(path: string | undefined) { + if (!path) return "" + const parts = path.split(".") + return parts[parts.length - 1] +} + +export function getFilenameTruncated(path: string | undefined, maxLength: number = 20) { + const filename = getFilename(path) + if (filename.length <= maxLength) return filename + const lastDot = filename.lastIndexOf(".") + const ext = lastDot <= 0 ? "" : filename.slice(lastDot) + const available = maxLength - ext.length - 1 // -1 for ellipsis + if (available <= 0) return filename.slice(0, maxLength - 1) + "…" + return filename.slice(0, available) + "…" + ext +} + +export function truncateMiddle(text: string, maxLength: number = 20) { + if (text.length <= maxLength) return text + const available = maxLength - 1 // -1 for ellipsis + const start = Math.ceil(available / 2) + const end = Math.floor(available / 2) + return text.slice(0, start) + "…" + text.slice(-end) +} diff --git a/packages/core/src/util/retry.ts b/packages/core/src/util/retry.ts new file mode 100644 index 000000000..831d23800 --- /dev/null +++ b/packages/core/src/util/retry.ts @@ -0,0 +1,42 @@ +export interface RetryOptions { + attempts?: number + delay?: number + factor?: number + maxDelay?: number + retryIf?: (error: unknown) => boolean +} + +const TRANSIENT_MESSAGES = [ + "load failed", + "network connection was lost", + "network request failed", + "failed to fetch", + "econnreset", + "econnrefused", + "etimedout", + "socket hang up", +] + +function isTransientError(error: unknown): boolean { + if (!error) return false + // oxlint-disable-next-line no-base-to-string -- error is unknown, intentional coercion for message matching + const message = String(error instanceof Error ? error.message : error).toLowerCase() + return TRANSIENT_MESSAGES.some((m) => message.includes(m)) +} + +export async function retry(fn: () => Promise, options: RetryOptions = {}): Promise { + const { attempts = 3, delay = 500, factor = 2, maxDelay = 10000, retryIf = isTransientError } = options + + let lastError: unknown + for (let attempt = 0; attempt < attempts; attempt++) { + try { + return await fn() + } catch (error) { + lastError = error + if (attempt === attempts - 1 || !retryIf(error)) throw error + const wait = Math.min(delay * Math.pow(factor, attempt), maxDelay) + await new Promise((resolve) => setTimeout(resolve, wait)) + } + } + throw lastError +} diff --git a/packages/core/src/util/slug.ts b/packages/core/src/util/slug.ts new file mode 100644 index 000000000..62cf0e57b --- /dev/null +++ b/packages/core/src/util/slug.ts @@ -0,0 +1,74 @@ +export namespace Slug { + const ADJECTIVES = [ + "brave", + "calm", + "clever", + "cosmic", + "crisp", + "curious", + "eager", + "gentle", + "glowing", + "happy", + "hidden", + "jolly", + "kind", + "lucky", + "mighty", + "misty", + "neon", + "nimble", + "playful", + "proud", + "quick", + "quiet", + "shiny", + "silent", + "stellar", + "sunny", + "swift", + "tidy", + "witty", + ] as const + + const NOUNS = [ + "cabin", + "cactus", + "canyon", + "circuit", + "comet", + "eagle", + "engine", + "falcon", + "forest", + "garden", + "harbor", + "island", + "knight", + "lagoon", + "meadow", + "moon", + "mountain", + "nebula", + "orchid", + "otter", + "panda", + "pixel", + "planet", + "river", + "rocket", + "sailor", + "squid", + "star", + "tiger", + "wizard", + "wolf", + ] as const + + export function create() { + return [ + ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)], + NOUNS[Math.floor(Math.random() * NOUNS.length)], + ].join("-") + } +} diff --git a/packages/core/sst-env.d.ts b/packages/core/sst-env.d.ts new file mode 100644 index 000000000..64441936d --- /dev/null +++ b/packages/core/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/core/test/filesystem/filesystem.test.ts b/packages/core/test/filesystem/filesystem.test.ts new file mode 100644 index 000000000..b77f4e356 --- /dev/null +++ b/packages/core/test/filesystem/filesystem.test.ts @@ -0,0 +1,338 @@ +import { describe, test, expect } from "bun:test" +import { Effect, Layer, FileSystem } from "effect" +import { NodeFileSystem } from "@effect/platform-node" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { testEffect } from "../lib/effect" +import path from "path" + +const live = AppFileSystem.layer.pipe(Layer.provideMerge(NodeFileSystem.layer)) +const { effect: it } = testEffect(live) + +describe("AppFileSystem", () => { + describe("isDir", () => { + it( + "returns true for directories", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + expect(yield* fs.isDir(tmp)).toBe(true) + }), + ) + + it( + "returns false for files", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const file = path.join(tmp, "test.txt") + yield* filesys.writeFileString(file, "hello") + expect(yield* fs.isDir(file)).toBe(false) + }), + ) + + it( + "returns false for non-existent paths", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + expect(yield* fs.isDir("/tmp/nonexistent-" + Math.random())).toBe(false) + }), + ) + }) + + describe("isFile", () => { + it( + "returns true for files", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const file = path.join(tmp, "test.txt") + yield* filesys.writeFileString(file, "hello") + expect(yield* fs.isFile(file)).toBe(true) + }), + ) + + it( + "returns false for directories", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + expect(yield* fs.isFile(tmp)).toBe(false) + }), + ) + }) + + describe("readJson / writeJson", () => { + it( + "round-trips JSON data", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const file = path.join(tmp, "data.json") + const data = { name: "test", count: 42, nested: { ok: true } } + + yield* fs.writeJson(file, data) + const result = yield* fs.readJson(file) + + expect(result).toEqual(data) + }), + ) + }) + + describe("ensureDir", () => { + it( + "creates nested directories", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const nested = path.join(tmp, "a", "b", "c") + + yield* fs.ensureDir(nested) + + const info = yield* filesys.stat(nested) + expect(info.type).toBe("Directory") + }), + ) + + it( + "is idempotent", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const dir = path.join(tmp, "existing") + yield* filesys.makeDirectory(dir) + + yield* fs.ensureDir(dir) + + const info = yield* filesys.stat(dir) + expect(info.type).toBe("Directory") + }), + ) + }) + + describe("writeWithDirs", () => { + it( + "creates parent directories if missing", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const file = path.join(tmp, "deep", "nested", "file.txt") + + yield* fs.writeWithDirs(file, "hello") + + expect(yield* filesys.readFileString(file)).toBe("hello") + }), + ) + + it( + "writes directly when parent exists", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const file = path.join(tmp, "direct.txt") + + yield* fs.writeWithDirs(file, "world") + + expect(yield* filesys.readFileString(file)).toBe("world") + }), + ) + + it( + "writes Uint8Array content", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const file = path.join(tmp, "binary.bin") + const content = new Uint8Array([0x00, 0x01, 0x02, 0x03]) + + yield* fs.writeWithDirs(file, content) + + const result = yield* filesys.readFile(file) + expect(new Uint8Array(result)).toEqual(content) + }), + ) + }) + + describe("findUp", () => { + it( + "finds target in start directory", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + yield* filesys.writeFileString(path.join(tmp, "target.txt"), "found") + + const result = yield* fs.findUp("target.txt", tmp) + expect(result).toEqual([path.join(tmp, "target.txt")]) + }), + ) + + it( + "finds target in parent directories", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + yield* filesys.writeFileString(path.join(tmp, "marker"), "root") + const child = path.join(tmp, "a", "b") + yield* filesys.makeDirectory(child, { recursive: true }) + + const result = yield* fs.findUp("marker", child, tmp) + expect(result).toEqual([path.join(tmp, "marker")]) + }), + ) + + it( + "returns empty array when not found", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const result = yield* fs.findUp("nonexistent", tmp, tmp) + expect(result).toEqual([]) + }), + ) + }) + + describe("up", () => { + it( + "finds multiple targets walking up", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + yield* filesys.writeFileString(path.join(tmp, "a.txt"), "a") + yield* filesys.writeFileString(path.join(tmp, "b.txt"), "b") + const child = path.join(tmp, "sub") + yield* filesys.makeDirectory(child) + yield* filesys.writeFileString(path.join(child, "a.txt"), "a-child") + + const result = yield* fs.up({ targets: ["a.txt", "b.txt"], start: child, stop: tmp }) + + expect(result).toContain(path.join(child, "a.txt")) + expect(result).toContain(path.join(tmp, "a.txt")) + expect(result).toContain(path.join(tmp, "b.txt")) + }), + ) + }) + + describe("glob", () => { + it( + "finds files matching pattern", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + yield* filesys.writeFileString(path.join(tmp, "a.ts"), "a") + yield* filesys.writeFileString(path.join(tmp, "b.ts"), "b") + yield* filesys.writeFileString(path.join(tmp, "c.json"), "c") + + const result = yield* fs.glob("*.ts", { cwd: tmp }) + expect(result.sort()).toEqual(["a.ts", "b.ts"]) + }), + ) + + it( + "supports absolute paths", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + yield* filesys.writeFileString(path.join(tmp, "file.txt"), "hello") + + const result = yield* fs.glob("*.txt", { cwd: tmp, absolute: true }) + expect(result).toEqual([path.join(tmp, "file.txt")]) + }), + ) + }) + + describe("globMatch", () => { + it( + "matches patterns", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + expect(fs.globMatch("*.ts", "foo.ts")).toBe(true) + expect(fs.globMatch("*.ts", "foo.json")).toBe(false) + expect(fs.globMatch("src/**", "src/a/b.ts")).toBe(true) + }), + ) + }) + + describe("globUp", () => { + it( + "finds files walking up directories", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + yield* filesys.writeFileString(path.join(tmp, "root.md"), "root") + const child = path.join(tmp, "a", "b") + yield* filesys.makeDirectory(child, { recursive: true }) + yield* filesys.writeFileString(path.join(child, "leaf.md"), "leaf") + + const result = yield* fs.globUp("*.md", child, tmp) + expect(result).toContain(path.join(child, "leaf.md")) + expect(result).toContain(path.join(tmp, "root.md")) + }), + ) + }) + + describe("built-in passthrough", () => { + it( + "exists works", + Effect.gen(function* () { + yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const file = path.join(tmp, "exists.txt") + yield* filesys.writeFileString(file, "yes") + + expect(yield* filesys.exists(file)).toBe(true) + expect(yield* filesys.exists(file + ".nope")).toBe(false) + }), + ) + + it( + "remove works", + Effect.gen(function* () { + yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const file = path.join(tmp, "delete-me.txt") + yield* filesys.writeFileString(file, "bye") + + yield* filesys.remove(file) + + expect(yield* filesys.exists(file)).toBe(false) + }), + ) + }) + + describe("pure helpers", () => { + test("mimeType returns correct types", () => { + expect(AppFileSystem.mimeType("file.json")).toBe("application/json") + expect(AppFileSystem.mimeType("image.png")).toBe("image/png") + expect(AppFileSystem.mimeType("unknown.qzx")).toBe("application/octet-stream") + }) + + test("contains checks path containment", () => { + expect(AppFileSystem.contains("/a/b", "/a/b/c")).toBe(true) + expect(AppFileSystem.contains("/a/b", "/a/c")).toBe(false) + }) + + test("overlaps detects overlapping paths", () => { + expect(AppFileSystem.overlaps("/a/b", "/a/b/c")).toBe(true) + expect(AppFileSystem.overlaps("/a/b/c", "/a/b")).toBe(true) + expect(AppFileSystem.overlaps("/a", "/b")).toBe(false) + }) + }) +}) diff --git a/packages/core/test/fixture/effect-flock-worker.ts b/packages/core/test/fixture/effect-flock-worker.ts new file mode 100644 index 000000000..3dc3ee2c8 --- /dev/null +++ b/packages/core/test/fixture/effect-flock-worker.ts @@ -0,0 +1,63 @@ +import fs from "fs/promises" +import os from "os" +import { Effect, Layer } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { Global } from "@opencode-ai/core/global" + +type Msg = { + key: string + dir: string + holdMs?: number + ready?: string + active?: string + done?: string +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +const msg: Msg = JSON.parse(process.argv[2]!) + +const testGlobal = Layer.succeed( + Global.Service, + Global.Service.of({ + home: os.homedir(), + data: os.tmpdir(), + cache: os.tmpdir(), + config: os.tmpdir(), + state: os.tmpdir(), + bin: os.tmpdir(), + log: os.tmpdir(), + }), +) + +const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer)) + +async function job() { + if (msg.ready) await fs.writeFile(msg.ready, String(process.pid)) + if (msg.active) await fs.writeFile(msg.active, String(process.pid), { flag: "wx" }) + + try { + if (msg.holdMs && msg.holdMs > 0) await sleep(msg.holdMs) + if (msg.done) await fs.appendFile(msg.done, "1\n") + } finally { + if (msg.active) await fs.rm(msg.active, { force: true }) + } +} + +await Effect.runPromise( + Effect.gen(function* () { + const flock = yield* EffectFlock.Service + yield* flock.withLock( + Effect.promise(() => job()), + msg.key, + msg.dir, + ) + }).pipe(Effect.provide(testLayer)), +).catch((err) => { + const text = err instanceof Error ? (err.stack ?? err.message) : String(err) + process.stderr.write(text) + process.exit(1) +}) diff --git a/packages/core/test/fixture/flock-worker.ts b/packages/core/test/fixture/flock-worker.ts new file mode 100644 index 000000000..0b9c314c0 --- /dev/null +++ b/packages/core/test/fixture/flock-worker.ts @@ -0,0 +1,72 @@ +import fs from "fs/promises" +import { Flock } from "@opencode-ai/core/util/flock" + +type Msg = { + key: string + dir: string + staleMs?: number + timeoutMs?: number + baseDelayMs?: number + maxDelayMs?: number + holdMs?: number + ready?: string + active?: string + done?: string +} + +function sleep(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +function input() { + const raw = process.argv[2] + if (!raw) { + throw new Error("Missing flock worker input") + } + + return JSON.parse(raw) as Msg +} + +async function job(input: Msg) { + if (input.ready) { + await fs.writeFile(input.ready, String(process.pid)) + } + + if (input.active) { + await fs.writeFile(input.active, String(process.pid), { flag: "wx" }) + } + + try { + if (input.holdMs && input.holdMs > 0) { + await sleep(input.holdMs) + } + + if (input.done) { + await fs.appendFile(input.done, "1\n") + } + } finally { + if (input.active) { + await fs.rm(input.active, { force: true }) + } + } +} + +async function main() { + const msg = input() + + await Flock.withLock(msg.key, () => job(msg), { + dir: msg.dir, + staleMs: msg.staleMs, + timeoutMs: msg.timeoutMs, + baseDelayMs: msg.baseDelayMs, + maxDelayMs: msg.maxDelayMs, + }) +} + +await main().catch((err) => { + const text = err instanceof Error ? (err.stack ?? err.message) : String(err) + process.stderr.write(text) + process.exit(1) +}) diff --git a/packages/core/test/lib/effect.ts b/packages/core/test/lib/effect.ts new file mode 100644 index 000000000..131ec5cc6 --- /dev/null +++ b/packages/core/test/lib/effect.ts @@ -0,0 +1,53 @@ +import { test, type TestOptions } from "bun:test" +import { Cause, Effect, Exit, Layer } from "effect" +import type * as Scope from "effect/Scope" +import * as TestClock from "effect/testing/TestClock" +import * as TestConsole from "effect/testing/TestConsole" + +type Body = Effect.Effect | (() => Effect.Effect) + +const body = (value: Body) => Effect.suspend(() => (typeof value === "function" ? value() : value)) + +const run = (value: Body, layer: Layer.Layer) => + Effect.gen(function* () { + const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit) + if (Exit.isFailure(exit)) { + for (const err of Cause.prettyErrors(exit.cause)) { + yield* Effect.logError(err) + } + } + return yield* exit + }).pipe(Effect.runPromise) + +const make = (testLayer: Layer.Layer, liveLayer: Layer.Layer) => { + const effect = (name: string, value: Body, opts?: number | TestOptions) => + test(name, () => run(value, testLayer), opts) + + effect.only = (name: string, value: Body, opts?: number | TestOptions) => + test.only(name, () => run(value, testLayer), opts) + + effect.skip = (name: string, value: Body, opts?: number | TestOptions) => + test.skip(name, () => run(value, testLayer), opts) + + const live = (name: string, value: Body, opts?: number | TestOptions) => + test(name, () => run(value, liveLayer), opts) + + live.only = (name: string, value: Body, opts?: number | TestOptions) => + test.only(name, () => run(value, liveLayer), opts) + + live.skip = (name: string, value: Body, opts?: number | TestOptions) => + test.skip(name, () => run(value, liveLayer), opts) + + return { effect, live } +} + +// Test environment with TestClock and TestConsole +const testEnv = Layer.mergeAll(TestConsole.layer, TestClock.layer()) + +// Live environment - uses real clock, but keeps TestConsole for output capture +const liveEnv = TestConsole.layer + +export const it = make(testEnv, liveEnv) + +export const testEffect = (layer: Layer.Layer) => + make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv)) diff --git a/packages/core/test/util/effect-flock.test.ts b/packages/core/test/util/effect-flock.test.ts new file mode 100644 index 000000000..9e8bc24ac --- /dev/null +++ b/packages/core/test/util/effect-flock.test.ts @@ -0,0 +1,389 @@ +import { describe, expect } from "bun:test" +import { spawn } from "child_process" +import fs from "fs/promises" +import path from "path" +import os from "os" +import { Cause, Effect, Exit, Layer } from "effect" +import { testEffect } from "../lib/effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { Global } from "@opencode-ai/core/global" +import { Hash } from "@opencode-ai/core/util/hash" + +function lock(dir: string, key: string) { + return path.join(dir, Hash.fast(key) + ".lock") +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +async function exists(file: string) { + return fs + .stat(file) + .then(() => true) + .catch(() => false) +} + +async function readJson(p: string): Promise { + return JSON.parse(await fs.readFile(p, "utf8")) +} + +// --------------------------------------------------------------------------- +// Worker subprocess helpers +// --------------------------------------------------------------------------- + +type Msg = { + key: string + dir: string + holdMs?: number + ready?: string + active?: string + done?: string +} + +const root = path.join(import.meta.dir, "../..") +const worker = path.join(import.meta.dir, "../fixture/effect-flock-worker.ts") + +function run(msg: Msg) { + return new Promise<{ code: number; stdout: Buffer; stderr: Buffer }>((resolve) => { + const proc = spawn(process.execPath, [worker, JSON.stringify(msg)], { cwd: root }) + const stdout: Buffer[] = [] + const stderr: Buffer[] = [] + proc.stdout?.on("data", (data) => stdout.push(Buffer.from(data))) + proc.stderr?.on("data", (data) => stderr.push(Buffer.from(data))) + proc.on("close", (code) => { + resolve({ code: code ?? 1, stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr) }) + }) + }) +} + +function spawnWorker(msg: Msg) { + return spawn(process.execPath, [worker, JSON.stringify(msg)], { + cwd: root, + stdio: ["ignore", "pipe", "pipe"], + }) +} + +function stopWorker(proc: ReturnType) { + if (proc.exitCode !== null || proc.signalCode !== null) return Promise.resolve() + if (process.platform !== "win32" || !proc.pid) { + proc.kill() + return Promise.resolve() + } + return new Promise((resolve) => { + const killProc = spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"]) + killProc.on("close", () => { + proc.kill() + resolve() + }) + }) +} + +async function waitForFile(file: string, timeout = 3_000) { + const stop = Date.now() + timeout + while (Date.now() < stop) { + if (await exists(file)) return + await sleep(20) + } + throw new Error(`Timed out waiting for file: ${file}`) +} + +// --------------------------------------------------------------------------- +// Test layer +// --------------------------------------------------------------------------- + +const testGlobal = Layer.succeed( + Global.Service, + Global.Service.of({ + home: os.homedir(), + data: os.tmpdir(), + cache: os.tmpdir(), + config: os.tmpdir(), + state: os.tmpdir(), + bin: os.tmpdir(), + log: os.tmpdir(), + }), +) + +const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer)) + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("util.effect-flock", () => { + const it = testEffect(testLayer) + + it.live( + "acquire and release via scoped Effect", + Effect.gen(function* () { + const flock = yield* EffectFlock.Service + const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) + const dir = path.join(tmp, "locks") + const lockDir = lock(dir, "eflock:acquire") + + yield* Effect.scoped(flock.acquire("eflock:acquire", dir)) + + expect(yield* Effect.promise(() => exists(lockDir))).toBe(false) + yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) + }), + ) + + it.live( + "withLock data-first", + Effect.gen(function* () { + const flock = yield* EffectFlock.Service + const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) + const dir = path.join(tmp, "locks") + + let hit = false + yield* flock.withLock( + Effect.sync(() => { + hit = true + }), + "eflock:df", + dir, + ) + expect(hit).toBe(true) + yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) + }), + ) + + it.live( + "withLock pipeable", + Effect.gen(function* () { + const flock = yield* EffectFlock.Service + const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) + const dir = path.join(tmp, "locks") + + let hit = false + yield* Effect.sync(() => { + hit = true + }).pipe(flock.withLock("eflock:pipe", dir)) + expect(hit).toBe(true) + yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) + }), + ) + + it.live( + "writes owner metadata", + Effect.gen(function* () { + const flock = yield* EffectFlock.Service + const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) + const dir = path.join(tmp, "locks") + const key = "eflock:meta" + const file = path.join(lock(dir, key), "meta.json") + + yield* Effect.scoped( + Effect.gen(function* () { + yield* flock.acquire(key, dir) + const json = yield* Effect.promise(() => + readJson<{ token?: unknown; pid?: unknown; hostname?: unknown; createdAt?: unknown }>(file), + ) + expect(typeof json.token).toBe("string") + expect(typeof json.pid).toBe("number") + expect(typeof json.hostname).toBe("string") + expect(typeof json.createdAt).toBe("string") + }), + ) + yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) + }), + ) + + it.live( + "breaks stale lock dirs", + Effect.gen(function* () { + const flock = yield* EffectFlock.Service + const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) + const dir = path.join(tmp, "locks") + const key = "eflock:stale" + const lockDir = lock(dir, key) + + yield* Effect.promise(async () => { + await fs.mkdir(lockDir, { recursive: true }) + const old = new Date(Date.now() - 120_000) + await fs.utimes(lockDir, old, old) + }) + + let hit = false + yield* flock.withLock( + Effect.sync(() => { + hit = true + }), + key, + dir, + ) + expect(hit).toBe(true) + yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) + }), + ) + + it.live( + "recovers from stale breaker", + Effect.gen(function* () { + const flock = yield* EffectFlock.Service + const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) + const dir = path.join(tmp, "locks") + const key = "eflock:stale-breaker" + const lockDir = lock(dir, key) + const breaker = lockDir + ".breaker" + + yield* Effect.promise(async () => { + await fs.mkdir(lockDir, { recursive: true }) + await fs.mkdir(breaker) + const old = new Date(Date.now() - 120_000) + await fs.utimes(lockDir, old, old) + await fs.utimes(breaker, old, old) + }) + + let hit = false + yield* flock.withLock( + Effect.sync(() => { + hit = true + }), + key, + dir, + ) + expect(hit).toBe(true) + expect(yield* Effect.promise(() => exists(breaker))).toBe(false) + yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) + }), + ) + + it.live( + "detects compromise when lock dir removed", + Effect.gen(function* () { + const flock = yield* EffectFlock.Service + const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) + const dir = path.join(tmp, "locks") + const key = "eflock:compromised" + const lockDir = lock(dir, key) + + const result = yield* flock + .withLock( + Effect.promise(() => fs.rm(lockDir, { recursive: true, force: true })), + key, + dir, + ) + .pipe(Effect.exit) + + expect(Exit.isFailure(result)).toBe(true) + expect(Exit.isFailure(result) ? Cause.pretty(result.cause) : "").toContain("missing") + yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) + }), + ) + + it.live( + "detects token mismatch", + Effect.gen(function* () { + const flock = yield* EffectFlock.Service + const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) + const dir = path.join(tmp, "locks") + const key = "eflock:token" + const lockDir = lock(dir, key) + const meta = path.join(lockDir, "meta.json") + + const result = yield* flock + .withLock( + Effect.promise(async () => { + const json = await readJson<{ token?: string }>(meta) + json.token = "tampered" + await fs.writeFile(meta, JSON.stringify(json, null, 2)) + }), + key, + dir, + ) + .pipe(Effect.exit) + + expect(Exit.isFailure(result)).toBe(true) + expect(Exit.isFailure(result) ? Cause.pretty(result.cause) : "").toContain("token mismatch") + expect(yield* Effect.promise(() => exists(lockDir))).toBe(true) + yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) + }), + ) + + it.live( + "fails on unwritable lock roots", + Effect.gen(function* () { + if (process.platform === "win32") return + const flock = yield* EffectFlock.Service + const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) + const dir = path.join(tmp, "locks") + + yield* Effect.promise(async () => { + await fs.mkdir(dir, { recursive: true }) + await fs.chmod(dir, 0o500) + }) + + const result = yield* flock.withLock(Effect.void, "eflock:perm", dir).pipe(Effect.exit) + // oxlint-disable-next-line no-base-to-string -- Exit has a useful toString for test assertions + expect(String(result)).toContain("PermissionDenied") + yield* Effect.promise(() => fs.chmod(dir, 0o700).then(() => fs.rm(tmp, { recursive: true, force: true }))) + }), + ) + + it.live( + "enforces mutual exclusion under process contention", + () => + Effect.promise(async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "eflock-stress-")) + const dir = path.join(tmp, "locks") + const done = path.join(tmp, "done.log") + const active = path.join(tmp, "active") + const n = 16 + + try { + const out = await Promise.all( + Array.from({ length: n }, () => run({ key: "eflock:stress", dir, done, active, holdMs: 30 })), + ) + + expect(out.map((x) => x.code)).toEqual(Array.from({ length: n }, () => 0)) + expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([]) + + const lines = (await fs.readFile(done, "utf8")) + .split("\n") + .map((x) => x.trim()) + .filter(Boolean) + expect(lines.length).toBe(n) + } finally { + await fs.rm(tmp, { recursive: true, force: true }) + } + }), + 60_000, + ) + + it.live( + "recovers after a crashed lock owner", + () => + Effect.promise(async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "eflock-crash-")) + const dir = path.join(tmp, "locks") + const ready = path.join(tmp, "ready") + + const proc = spawnWorker({ key: "eflock:crash", dir, ready, holdMs: 120_000 }) + + try { + await waitForFile(ready, 5_000) + await stopWorker(proc) + await new Promise((resolve) => proc.on("close", resolve)) + + // Backdate lock files so they're past STALE_MS (60s) + const lockDir = lock(dir, "eflock:crash") + const old = new Date(Date.now() - 120_000) + await fs.utimes(lockDir, old, old).catch(() => {}) + await fs.utimes(path.join(lockDir, "heartbeat"), old, old).catch(() => {}) + await fs.utimes(path.join(lockDir, "meta.json"), old, old).catch(() => {}) + + const done = path.join(tmp, "done.log") + const result = await run({ key: "eflock:crash", dir, done, holdMs: 10 }) + expect(result.code).toBe(0) + expect(result.stderr.toString()).toBe("") + } finally { + await stopWorker(proc).catch(() => {}) + await fs.rm(tmp, { recursive: true, force: true }) + } + }), + 30_000, + ) +}) diff --git a/packages/core/test/util/flock.test.ts b/packages/core/test/util/flock.test.ts new file mode 100644 index 000000000..e1b647b64 --- /dev/null +++ b/packages/core/test/util/flock.test.ts @@ -0,0 +1,426 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import { spawn } from "child_process" +import path from "path" +import os from "os" +import { Flock } from "@opencode-ai/core/util/flock" +import { Hash } from "@opencode-ai/core/util/hash" + +type Msg = { + key: string + dir: string + staleMs?: number + timeoutMs?: number + baseDelayMs?: number + maxDelayMs?: number + holdMs?: number + ready?: string + active?: string + done?: string +} + +const root = path.join(import.meta.dir, "../..") +const worker = path.join(import.meta.dir, "../fixture/flock-worker.ts") + +async function tmpdir() { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "flock-test-")) + return { + path: dir, + async [Symbol.asyncDispose]() { + await fs.rm(dir, { recursive: true, force: true }) + }, + } +} + +function lock(dir: string, key: string) { + return path.join(dir, Hash.fast(key) + ".lock") +} + +function sleep(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +async function exists(file: string) { + return fs + .stat(file) + .then(() => true) + .catch(() => false) +} + +async function wait(file: string, timeout = 3_000) { + const stop = Date.now() + timeout + while (Date.now() < stop) { + if (await exists(file)) return + await sleep(20) + } + + throw new Error(`Timed out waiting for file: ${file}`) +} + +function run(msg: Msg) { + return new Promise<{ code: number; stdout: Buffer; stderr: Buffer }>((resolve) => { + const proc = spawn(process.execPath, [worker, JSON.stringify(msg)], { + cwd: root, + }) + + const stdout: Buffer[] = [] + const stderr: Buffer[] = [] + + proc.stdout?.on("data", (data) => stdout.push(Buffer.from(data))) + proc.stderr?.on("data", (data) => stderr.push(Buffer.from(data))) + + proc.on("close", (code) => { + resolve({ + code: code ?? 1, + stdout: Buffer.concat(stdout), + stderr: Buffer.concat(stderr), + }) + }) + }) +} + +function spawnWorker(msg: Msg) { + return spawn(process.execPath, [worker, JSON.stringify(msg)], { + cwd: root, + stdio: ["ignore", "pipe", "pipe"], + }) +} + +function stopWorker(proc: ReturnType) { + if (proc.exitCode !== null || proc.signalCode !== null) return Promise.resolve() + + if (process.platform !== "win32" || !proc.pid) { + proc.kill() + return Promise.resolve() + } + + return new Promise((resolve) => { + const killProc = spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"]) + killProc.on("close", () => { + proc.kill() + resolve() + }) + }) +} + +async function readJson(p: string): Promise { + return JSON.parse(await fs.readFile(p, "utf8")) +} + +describe("util.flock", () => { + test("enforces mutual exclusion under process contention", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "locks") + const done = path.join(tmp.path, "done.log") + const active = path.join(tmp.path, "active") + const key = "flock:stress" + const n = 16 + + const out = await Promise.all( + Array.from({ length: n }, () => + run({ + key, + dir, + done, + active, + holdMs: 30, + staleMs: 1_000, + timeoutMs: 15_000, + }), + ), + ) + + expect(out.map((x) => x.code)).toEqual(Array.from({ length: n }, () => 0)) + expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([]) + + const lines = (await fs.readFile(done, "utf8")) + .split("\n") + .map((x) => x.trim()) + .filter(Boolean) + expect(lines.length).toBe(n) + }, 20_000) + + test("times out while waiting when lock is still healthy", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "locks") + const key = "flock:timeout" + const ready = path.join(tmp.path, "ready") + const proc = spawnWorker({ + key, + dir, + ready, + holdMs: 20_000, + staleMs: 10_000, + timeoutMs: 30_000, + }) + + try { + await wait(ready, 5_000) + const seen: string[] = [] + const err = await Flock.withLock(key, async () => {}, { + dir, + staleMs: 10_000, + timeoutMs: 1_000, + onWait: (tick) => { + seen.push(tick.key) + }, + }).catch((err) => err) + + expect(err).toBeInstanceOf(Error) + if (!(err instanceof Error)) throw err + expect(err.message).toContain("Timed out waiting for lock") + expect(seen.length).toBeGreaterThan(0) + expect(seen.every((x) => x === key)).toBe(true) + } finally { + await stopWorker(proc).catch(() => undefined) + await new Promise((resolve) => proc.on("close", resolve)) + } + }, 15_000) + + test("recovers after a crashed lock owner", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "locks") + const key = "flock:crash" + const ready = path.join(tmp.path, "ready") + const proc = spawnWorker({ + key, + dir, + ready, + holdMs: 20_000, + staleMs: 500, + timeoutMs: 30_000, + }) + + await wait(ready, 5_000) + await stopWorker(proc) + await new Promise((resolve) => proc.on("close", resolve)) + + let hit = false + await Flock.withLock( + key, + async () => { + hit = true + }, + { + dir, + staleMs: 500, + timeoutMs: 8_000, + }, + ) + + expect(hit).toBe(true) + }, 20_000) + + test("breaks stale lock dirs when heartbeat is missing", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "locks") + const key = "flock:missing-heartbeat" + const lockDir = lock(dir, key) + + await fs.mkdir(lockDir, { recursive: true }) + const old = new Date(Date.now() - 2_000) + await fs.utimes(lockDir, old, old) + + let hit = false + await Flock.withLock( + key, + async () => { + hit = true + }, + { + dir, + staleMs: 200, + timeoutMs: 3_000, + }, + ) + + expect(hit).toBe(true) + }) + + test("recovers when a stale breaker claim was left behind", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "locks") + const key = "flock:stale-breaker" + const lockDir = lock(dir, key) + const breaker = lockDir + ".breaker" + + await fs.mkdir(lockDir, { recursive: true }) + await fs.mkdir(breaker) + + const old = new Date(Date.now() - 2_000) + await fs.utimes(lockDir, old, old) + await fs.utimes(breaker, old, old) + + let hit = false + await Flock.withLock( + key, + async () => { + hit = true + }, + { + dir, + staleMs: 200, + timeoutMs: 3_000, + }, + ) + + expect(hit).toBe(true) + expect(await exists(breaker)).toBe(false) + }) + + test("fails clearly if lock dir is removed while held", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "locks") + const key = "flock:compromised" + const lockDir = lock(dir, key) + + const err = await Flock.withLock( + key, + async () => { + await fs.rm(lockDir, { + recursive: true, + force: true, + }) + }, + { + dir, + staleMs: 1_000, + timeoutMs: 3_000, + }, + ).catch((err) => err) + + expect(err).toBeInstanceOf(Error) + if (!(err instanceof Error)) throw err + expect(err.message).toContain("compromised") + + let hit = false + await Flock.withLock( + key, + async () => { + hit = true + }, + { + dir, + staleMs: 200, + timeoutMs: 3_000, + }, + ) + expect(hit).toBe(true) + }) + + test("writes owner metadata while lock is held", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "locks") + const key = "flock:meta" + const file = path.join(lock(dir, key), "meta.json") + + await Flock.withLock( + key, + async () => { + const json = await readJson<{ + token?: unknown + pid?: unknown + hostname?: unknown + createdAt?: unknown + }>(file) + + expect(typeof json.token).toBe("string") + expect(typeof json.pid).toBe("number") + expect(typeof json.hostname).toBe("string") + expect(typeof json.createdAt).toBe("string") + }, + { + dir, + staleMs: 1_000, + timeoutMs: 3_000, + }, + ) + }) + + test("supports acquire with await using", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "locks") + const key = "flock:acquire" + const lockDir = lock(dir, key) + + { + await using _ = await Flock.acquire(key, { + dir, + staleMs: 1_000, + timeoutMs: 3_000, + }) + expect(await exists(lockDir)).toBe(true) + } + + expect(await exists(lockDir)).toBe(false) + }) + + test("refuses token mismatch release and recovers from stale", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "locks") + const key = "flock:token" + const lockDir = lock(dir, key) + const meta = path.join(lockDir, "meta.json") + + const err = await Flock.withLock( + key, + async () => { + const json = await readJson<{ token?: string }>(meta) + json.token = "tampered" + await fs.writeFile(meta, JSON.stringify(json, null, 2)) + }, + { + dir, + staleMs: 500, + timeoutMs: 3_000, + }, + ).catch((err) => err) + + expect(err).toBeInstanceOf(Error) + if (!(err instanceof Error)) throw err + expect(err.message).toContain("token mismatch") + expect(await exists(lockDir)).toBe(true) + + let hit = false + await Flock.withLock( + key, + async () => { + hit = true + }, + { + dir, + staleMs: 500, + timeoutMs: 6_000, + }, + ) + expect(hit).toBe(true) + }) + + test("fails clearly on unwritable lock roots", async () => { + if (process.platform === "win32") return + + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "locks") + const key = "flock:perm" + + await fs.mkdir(dir, { recursive: true }) + await fs.chmod(dir, 0o500) + + try { + const err = await Flock.withLock(key, async () => {}, { + dir, + staleMs: 100, + timeoutMs: 500, + }).catch((err) => err) + + expect(err).toBeInstanceOf(Error) + if (!(err instanceof Error)) throw err + const text = err.message + expect(text.includes("EACCES") || text.includes("EPERM")).toBe(true) + } finally { + await fs.chmod(dir, 0o700) + } + }) +}) diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 000000000..d7745d755 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "noUncheckedIndexedAccess": false, + "plugins": [ + { + "name": "@effect/language-service", + "transform": "@effect/language-service/transform", + "namespaceImportPackages": ["effect", "@effect/*"] + } + ] + } +} diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 5f6b14ed7..a5a2997b9 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -13,7 +13,7 @@ "shell-prod": "sst shell --target Teams --stage production" }, "dependencies": { - "@opencode-ai/shared": "workspace:*", + "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", "aws4fetch": "^1.0.20", "@pierre/diffs": "catalog:", diff --git a/packages/enterprise/src/core/share.ts b/packages/enterprise/src/core/share.ts index 1a343272f..fb8cd3029 100644 --- a/packages/enterprise/src/core/share.ts +++ b/packages/enterprise/src/core/share.ts @@ -1,6 +1,6 @@ import { Message, Model, Part, Session, SnapshotFileDiff } from "@opencode-ai/sdk/v2" -import { fn } from "@opencode-ai/shared/util/fn" -import { iife } from "@opencode-ai/shared/util/iife" +import { fn } from "@opencode-ai/core/util/fn" +import { iife } from "@opencode-ai/core/util/iife" import z from "zod" import { Storage } from "./storage" diff --git a/packages/enterprise/src/core/storage.ts b/packages/enterprise/src/core/storage.ts index a6222e415..58d61aca7 100644 --- a/packages/enterprise/src/core/storage.ts +++ b/packages/enterprise/src/core/storage.ts @@ -1,5 +1,5 @@ import { AwsClient } from "aws4fetch" -import { lazy } from "@opencode-ai/shared/util/lazy" +import { lazy } from "@opencode-ai/core/util/lazy" export namespace Storage { export interface Adapter { diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx index f3be14e39..b12afce27 100644 --- a/packages/enterprise/src/routes/share/[shareID].tsx +++ b/packages/enterprise/src/routes/share/[shareID].tsx @@ -10,9 +10,9 @@ import { Share } from "~/core/share" import { Logo, Mark } from "@opencode-ai/ui/logo" import { IconButton } from "@opencode-ai/ui/icon-button" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" -import { iife } from "@opencode-ai/shared/util/iife" -import { Binary } from "@opencode-ai/shared/util/binary" -import { NamedError } from "@opencode-ai/shared/util/error" +import { iife } from "@opencode-ai/core/util/iife" +import { Binary } from "@opencode-ai/core/util/binary" +import { NamedError } from "@opencode-ai/core/util/error" import { DateTime } from "luxon" import { createStore } from "solid-js/store" import z from "zod" diff --git a/packages/enterprise/test/core/share.test.ts b/packages/enterprise/test/core/share.test.ts index 2877f8e0e..15c5f9205 100644 --- a/packages/enterprise/test/core/share.test.ts +++ b/packages/enterprise/test/core/share.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import { Share } from "../../src/core/share" import { Storage } from "../../src/core/storage" -import { Identifier } from "@opencode-ai/shared/util/identifier" +import { Identifier } from "@opencode-ai/core/util/identifier" describe.concurrent("core.share", () => { test("should create a share", async () => { diff --git a/packages/opencode/package.json b/packages/opencode/package.json index a0b6ddaff..1c60e58a8 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -45,7 +45,7 @@ "@effect/language-service": "0.84.2", "@octokit/webhooks-types": "7.6.1", "@opencode-ai/script": "workspace:*", - "@opencode-ai/shared": "workspace:*", + "@opencode-ai/core": "workspace:*", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 24bcb9c2d..6ab24e26b 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -34,7 +34,7 @@ import { import { Log } from "../util" import { pathToFileURL } from "url" import { Filesystem } from "../util" -import { Hash } from "@opencode-ai/shared/util/hash" +import { Hash } from "@opencode-ai/core/util/hash" import { ACPSessionManager } from "./session" import type { ACPConfig } from "./types" import { Provider } from "../provider" diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 5b4b5120f..00bc22329 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -2,7 +2,7 @@ import path from "path" import { Effect, Layer, Record, Result, Schema, Context } from "effect" import { zod } from "@/util/effect-zod" import { Global } from "../global" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index 9d5cd65bf..8dc6ab07e 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -10,7 +10,7 @@ import { TuiInfo } from "./tui-schema" import { Flag } from "@/flag/flag" import { isRecord } from "@/util/record" import { Global } from "@/global" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CurrentWorkingDirectory } from "./cwd" import { ConfigPlugin } from "@/config/plugin" import { ConfigKeybinds } from "@/config/keybinds" diff --git a/packages/opencode/src/cli/cmd/tui/context/kv.tsx b/packages/opencode/src/cli/cmd/tui/context/kv.tsx index 43266315b..df8a8394c 100644 --- a/packages/opencode/src/cli/cmd/tui/context/kv.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/kv.tsx @@ -1,6 +1,6 @@ import { Global } from "@/global" import { Filesystem } from "@/util" -import { Flock } from "@opencode-ai/shared/util/flock" +import { Flock } from "@opencode-ai/core/util/flock" import { rename, rm } from "fs/promises" import { createSignal, type Setter } from "solid-js" import { createStore, unwrap } from "solid-js/store" diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 57326e3a1..d35deb0b6 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -22,7 +22,7 @@ import { createStore, produce, reconcile } from "solid-js/store" import { useProject } from "@tui/context/project" import { useEvent } from "@tui/context/event" import { useSDK } from "@tui/context/sdk" -import { Binary } from "@opencode-ai/shared/util/binary" +import { Binary } from "@opencode-ai/core/util/binary" import { createSimpleContext } from "./helper" import type { Snapshot } from "@/snapshot" import { useExit } from "./exit" diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 3ae1eb869..10f2dc49d 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -2,7 +2,7 @@ import { CliRenderEvents, SyntaxStyle, RGBA, type TerminalColors } from "@opentu import path from "path" import { createEffect, createMemo, onCleanup, onMount } from "solid-js" import { createSimpleContext } from "./helper" -import { Glob } from "@opencode-ai/shared/util/glob" +import { Glob } from "@opencode-ai/core/util/glob" import aura from "./theme/aura.json" with { type: "json" } import ayu from "./theme/ayu.json" with { type: "json" } import catppuccin from "./theme/catppuccin.json" with { type: "json" } diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index e4a0e59eb..8eda7e022 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -32,7 +32,7 @@ import { hasTheme, upsertTheme } from "../context/theme" import { Global } from "@/global" import { Filesystem } from "@/util" import { Process } from "@/util" -import { Flock } from "@opencode-ai/shared/util/flock" +import { Flock } from "@opencode-ai/core/util/flock" import { Flag } from "@/flag/flag" import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal" import { setupSlots, Slot as View } from "./slots" diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index f286b5166..adf52f568 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -1,4 +1,4 @@ -import { NamedError } from "@opencode-ai/shared/util/error" +import { NamedError } from "@opencode-ai/core/util/error" import { errorFormat } from "@/util/error" interface ErrorLike { diff --git a/packages/opencode/src/cli/ui.ts b/packages/opencode/src/cli/ui.ts index 46335d24a..7b4cf7f34 100644 --- a/packages/opencode/src/cli/ui.ts +++ b/packages/opencode/src/cli/ui.ts @@ -1,6 +1,6 @@ import z from "zod" import { EOL } from "os" -import { NamedError } from "@opencode-ai/shared/util/error" +import { NamedError } from "@opencode-ai/core/util/error" import { logo as glyphs } from "./logo" const wordmark = [ diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index 2978916b5..a8693c8aa 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -6,8 +6,8 @@ import { Bus } from "@/bus" import { zod } from "@/util/effect-zod" import { PositiveInt } from "@/util/schema" import { Log } from "../util" -import { NamedError } from "@opencode-ai/shared/util/error" -import { Glob } from "@opencode-ai/shared/util/glob" +import { NamedError } from "@opencode-ai/core/util/error" +import { Glob } from "@opencode-ai/core/util/glob" import { configEntryNameFromPath } from "./entry-name" import { InvalidError } from "./error" import * as ConfigMarkdown from "./markdown" diff --git a/packages/opencode/src/config/command.ts b/packages/opencode/src/config/command.ts index 3e0adccc3..36cae6f97 100644 --- a/packages/opencode/src/config/command.ts +++ b/packages/opencode/src/config/command.ts @@ -2,8 +2,8 @@ export * as ConfigCommand from "./command" import { Log } from "../util" import { Schema } from "effect" -import { NamedError } from "@opencode-ai/shared/util/error" -import { Glob } from "@opencode-ai/shared/util/glob" +import { NamedError } from "@opencode-ai/core/util/error" +import { Glob } from "@opencode-ai/core/util/glob" import { Bus } from "@/bus" import { zod } from "@/util/effect-zod" import { withStatics } from "@/util/schema" diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index f1ceb1b4e..3238287be 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -6,7 +6,7 @@ import z from "zod" import { mergeDeep, pipe } from "remeda" import { Global } from "../global" import fsNode from "fs/promises" -import { NamedError } from "@opencode-ai/shared/util/error" +import { NamedError } from "@opencode-ai/core/util/error" import { Flag } from "../flag/flag" import { Auth } from "../auth" import { Env } from "../env" @@ -19,10 +19,10 @@ import { Event } from "../server/event" import { Account } from "@/account/account" import { isRecord } from "@/util/record" import type { ConsoleState } from "./console-state" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { InstanceState } from "@/effect" import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect" -import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { InstanceRef } from "@/effect/instance-ref" import { zod, ZodOverride } from "@/util/effect-zod" import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@/util/schema" diff --git a/packages/opencode/src/config/error.ts b/packages/opencode/src/config/error.ts index 06f549fd8..c43598048 100644 --- a/packages/opencode/src/config/error.ts +++ b/packages/opencode/src/config/error.ts @@ -1,7 +1,7 @@ export * as ConfigError from "./error" import z from "zod" -import { NamedError } from "@opencode-ai/shared/util/error" +import { NamedError } from "@opencode-ai/core/util/error" export const JsonError = NamedError.create( "ConfigJsonError", diff --git a/packages/opencode/src/config/markdown.ts b/packages/opencode/src/config/markdown.ts index 7cad69266..d782d655e 100644 --- a/packages/opencode/src/config/markdown.ts +++ b/packages/opencode/src/config/markdown.ts @@ -1,4 +1,4 @@ -import { NamedError } from "@opencode-ai/shared/util/error" +import { NamedError } from "@opencode-ai/core/util/error" import matter from "gray-matter" import { z } from "zod" import { Filesystem } from "../util" diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts index db4b914f7..572676fcc 100644 --- a/packages/opencode/src/config/paths.ts +++ b/packages/opencode/src/config/paths.ts @@ -7,7 +7,7 @@ import { Global } from "@/global" import { unique } from "remeda" import { JsonError } from "./error" import * as Effect from "effect/Effect" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" export const files = Effect.fn("ConfigPaths.projectFiles")(function* ( name: string, diff --git a/packages/opencode/src/config/plugin.ts b/packages/opencode/src/config/plugin.ts index 4277c1cd6..9667dbb59 100644 --- a/packages/opencode/src/config/plugin.ts +++ b/packages/opencode/src/config/plugin.ts @@ -1,4 +1,4 @@ -import { Glob } from "@opencode-ai/shared/util/glob" +import { Glob } from "@opencode-ai/core/util/glob" import { Schema } from "effect" import { pathToFileURL } from "url" import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 107f2d990..e1ebb613e 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -12,7 +12,7 @@ import { Flag } from "@/flag/flag" import { Log } from "@/util" import { Filesystem } from "@/util" import { ProjectID } from "@/project/schema" -import { Slug } from "@opencode-ai/shared/util/slug" +import { Slug } from "@opencode-ai/core/util/slug" import { WorkspaceTable } from "./workspace.sql" import { getAdaptor } from "./adaptors" import { type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types" diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index d68e00a32..6c9d949b8 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -2,7 +2,7 @@ import { Layer, ManagedRuntime } from "effect" import { attach } from "./run-service" import * as Observability from "./observability" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Bus } from "@/bus" import { Auth } from "@/auth" import { Account } from "@/account/account" diff --git a/packages/opencode/src/file/ignore.ts b/packages/opencode/src/file/ignore.ts index efce87280..68c359b9a 100644 --- a/packages/opencode/src/file/ignore.ts +++ b/packages/opencode/src/file/ignore.ts @@ -1,4 +1,4 @@ -import { Glob } from "@opencode-ai/shared/util/glob" +import { Glob } from "@opencode-ai/core/util/glob" const FOLDERS = new Set([ "node_modules", diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 05e2ce359..4710fd76d 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -1,7 +1,7 @@ import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Git } from "@/git" import { Effect, Layer, Context, Schema, Scope } from "effect" import * as Stream from "effect/Stream" diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 64a2e3d8e..e31f53733 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -1,5 +1,5 @@ import path from "path" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Cause, Context, Effect, Fiber, Layer, Queue, Schema, Stream } from "effect" import type { PlatformError } from "effect/PlatformError" import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" diff --git a/packages/opencode/src/global/index.ts b/packages/opencode/src/global/index.ts index 27bac598f..7f48a0f88 100644 --- a/packages/opencode/src/global/index.ts +++ b/packages/opencode/src/global/index.ts @@ -3,7 +3,7 @@ import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir" import path from "path" import os from "os" import { Filesystem } from "../util" -import { Flock } from "@opencode-ai/shared/util/flock" +import { Flock } from "@opencode-ai/core/util/flock" const app = "opencode" diff --git a/packages/opencode/src/ide/index.ts b/packages/opencode/src/ide/index.ts index f9ce1ec63..4a2576f68 100644 --- a/packages/opencode/src/ide/index.ts +++ b/packages/opencode/src/ide/index.ts @@ -1,7 +1,7 @@ import { BusEvent } from "@/bus/bus-event" import z from "zod" import { Schema } from "effect" -import { NamedError } from "@opencode-ai/shared/util/error" +import { NamedError } from "@opencode-ai/core/util/error" import { Log } from "../util" import { Process } from "@/util" diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 0a3a927b4..c27f6b740 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -12,7 +12,7 @@ import { ModelsCommand } from "./cli/cmd/models" import { UI } from "./cli/ui" import { Installation } from "./installation" import { InstallationVersion } from "./installation/version" -import { NamedError } from "@opencode-ai/shared/util/error" +import { NamedError } from "@opencode-ai/core/util/error" import { FormatError } from "./cli/error" import { ServeCommand } from "./cli/cmd/serve" import { Filesystem } from "./util" diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index e8050babf..4eaa32f77 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -10,7 +10,7 @@ import { LANGUAGE_EXTENSIONS } from "./language" import z from "zod" import { Schema } from "effect" import type * as LSPServer from "./server" -import { NamedError } from "@opencode-ai/shared/util/error" +import { NamedError } from "@opencode-ai/core/util/error" import { withTimeout } from "../util/timeout" import { Filesystem } from "../util" diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 7741ff60e..5078cbadb 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -12,7 +12,7 @@ import { Process } from "../util" import { spawn as lspspawn } from "./launch" import { Effect, Layer, Context, Schema } from "effect" import { InstanceState } from "@/effect" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { withStatics } from "@/util/schema" import { zod, ZodOverride } from "@/util/effect-zod" diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 7faaeb42f..ef001888e 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -11,7 +11,7 @@ import { Flag } from "../flag/flag" import { Archive } from "../util" import { Process } from "../util" import { which } from "../util/which" -import { Module } from "@opencode-ai/shared/util/module" +import { Module } from "@opencode-ai/core/util/module" import { spawn } from "./launch" import { Npm } from "../npm" diff --git a/packages/opencode/src/mcp/auth.ts b/packages/opencode/src/mcp/auth.ts index efb046d7a..0a57fa141 100644 --- a/packages/opencode/src/mcp/auth.ts +++ b/packages/opencode/src/mcp/auth.ts @@ -2,7 +2,7 @@ import path from "path" import z from "zod" import { Global } from "../global" import { Effect, Layer, Context } from "effect" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" export const Tokens = z.object({ accessToken: z.string(), diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 3c6816c5b..8b2562dc4 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -12,12 +12,12 @@ import { import { Config } from "../config" import { ConfigMCP } from "../config/mcp" import { Log } from "../util" -import { NamedError } from "@opencode-ai/shared/util/error" +import { NamedError } from "@opencode-ai/core/util/error" import z from "zod/v4" import { Installation } from "../installation" import { InstallationVersion } from "../installation/version" import { withTimeout } from "@/util/timeout" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { McpOAuthProvider } from "./oauth-provider" import { McpOAuthCallback } from "./oauth-callback" import { McpAuth } from "./auth" diff --git a/packages/opencode/src/npm/index.ts b/packages/opencode/src/npm/index.ts index 4b1f80707..d876b0e52 100644 --- a/packages/opencode/src/npm/index.ts +++ b/packages/opencode/src/npm/index.ts @@ -8,9 +8,9 @@ import Config from "@npmcli/config" import { definitions, flatten, nerfDarts, shorthands } from "@npmcli/config/lib/definitions/index.js" import { Effect, Schema, Context, Layer, Option, FileSystem, Stream } from "effect" import { NodeFileSystem } from "@effect/platform-node" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" -import { Global } from "@opencode-ai/shared/global" -import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Global } from "@opencode-ai/core/global" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import * as CrossSpawnSpawner from "../effect/cross-spawn-spawner" diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index dd2a78469..4587d8fb1 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -12,7 +12,7 @@ import { createOpencodeClient } from "@opencode-ai/sdk" import { Flag } from "../flag/flag" import { CodexAuthPlugin } from "./codex" import { Session } from "../session" -import { NamedError } from "@opencode-ai/shared/util/error" +import { NamedError } from "@opencode-ai/core/util/error" import { CopilotAuthPlugin } from "./github-copilot/copilot" import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" import { PoeAuthPlugin } from "opencode-poe-auth" diff --git a/packages/opencode/src/plugin/install.ts b/packages/opencode/src/plugin/install.ts index 0525a7ba0..87798f56d 100644 --- a/packages/opencode/src/plugin/install.ts +++ b/packages/opencode/src/plugin/install.ts @@ -10,7 +10,7 @@ import { import * as ConfigPaths from "@/config/paths" import { Global } from "@/global" import { Filesystem } from "@/util" -import { Flock } from "@opencode-ai/shared/util/flock" +import { Flock } from "@opencode-ai/core/util/flock" import { isRecord } from "@/util/record" import { parsePluginSpecifier, readPackageThemes, readPluginPackage, resolvePluginTarget } from "./shared" diff --git a/packages/opencode/src/plugin/meta.ts b/packages/opencode/src/plugin/meta.ts index 86ad8fbab..4c14a0dec 100644 --- a/packages/opencode/src/plugin/meta.ts +++ b/packages/opencode/src/plugin/meta.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from "url" import { Flag } from "@/flag/flag" import { Global } from "@/global" import { Filesystem } from "@/util" -import { Flock } from "@opencode-ai/shared/util/flock" +import { Flock } from "@opencode-ai/core/util/flock" import { parsePluginSpecifier, pluginSource } from "./shared" diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 1c5109620..cd2013674 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -1,7 +1,7 @@ import { GlobalBus } from "@/bus/global" import { disposeInstance } from "@/effect/instance-registry" import { makeRuntime } from "@/effect/run-service" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { iife } from "@/util/iife" import { Log } from "@/util" import { LocalContext } from "../util" diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 70a959064..88d033921 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -11,7 +11,7 @@ import { ProjectID } from "./schema" import { Effect, Layer, Path, Scope, Context, Stream, Types, Schema } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import { zod } from "@/util/effect-zod" import { withStatics } from "@/util/schema" diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 1c1da97bf..2fbab4f63 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -4,7 +4,7 @@ import path from "path" import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { FileWatcher } from "@/file/watcher" import { Git } from "@/git" import { Log } from "@/util" diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 36c4d8c23..e52464d6d 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -6,8 +6,8 @@ import { Installation } from "../installation" import { Flag } from "../flag/flag" import { lazy } from "@/util/lazy" import { Filesystem } from "../util" -import { Flock } from "@opencode-ai/shared/util/flock" -import { Hash } from "@opencode-ai/shared/util/hash" +import { Flock } from "@opencode-ai/core/util/flock" +import { Hash } from "@opencode-ai/core/util/hash" // Try to import bundled snapshot (generated at build time) // Falls back to undefined in dev mode when snapshot doesn't exist diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 0fe53e6e4..d6ccbacfc 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -5,7 +5,7 @@ import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda" import { NoSuchModelError, type Provider as SDK } from "ai" import { Log } from "../util" import { Npm } from "../npm" -import { Hash } from "@opencode-ai/shared/util/hash" +import { Hash } from "@opencode-ai/core/util/hash" import { Plugin } from "../plugin" import { type LanguageModelV3 } from "@ai-sdk/provider" import * as ModelsDev from "./models" @@ -22,7 +22,7 @@ import { pathToFileURL } from "url" import { Effect, Layer, Context, Schema, Types } from "effect" import { EffectBridge } from "@/effect" import { InstanceState } from "@/effect" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { isRecord } from "@/util/record" import { withStatics } from "@/util/schema" diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 604fa77fb..918f4f86c 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -4,7 +4,7 @@ import { InstanceState } from "@/effect" import { Instance } from "@/project/instance" import type { Proc } from "#pty" import { Log } from "../util" -import { lazy } from "@opencode-ai/shared/util/lazy" +import { lazy } from "@opencode-ai/core/util/lazy" import { Shell } from "@/shell/shell" import { Plugin } from "@/plugin" import { PtyID } from "./schema" diff --git a/packages/opencode/src/server/middleware.ts b/packages/opencode/src/server/middleware.ts index b67d15f55..55d9dee79 100644 --- a/packages/opencode/src/server/middleware.ts +++ b/packages/opencode/src/server/middleware.ts @@ -1,5 +1,5 @@ import { Provider } from "../provider" -import { NamedError } from "@opencode-ai/shared/util/error" +import { NamedError } from "@opencode-ai/core/util/error" import { NotFoundError } from "../storage" import { Session } from "../session" import type { ContentfulStatusCode } from "hono/utils/http-status" diff --git a/packages/opencode/src/server/routes/instance/middleware.ts b/packages/opencode/src/server/routes/instance/middleware.ts index b963268d6..19918b8b4 100644 --- a/packages/opencode/src/server/routes/instance/middleware.ts +++ b/packages/opencode/src/server/routes/instance/middleware.ts @@ -2,7 +2,7 @@ import type { MiddlewareHandler } from "hono" import { Instance } from "@/project/instance" import { InstanceBootstrap } from "@/project/bootstrap" import { AppRuntime } from "@/effect/app-runtime" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { WorkspaceContext } from "@/control-plane/workspace-context" import { WorkspaceID } from "@/control-plane/schema" diff --git a/packages/opencode/src/server/routes/instance/session.ts b/packages/opencode/src/server/routes/instance/session.ts index 4f4f8ed86..52a803467 100644 --- a/packages/opencode/src/server/routes/instance/session.ts +++ b/packages/opencode/src/server/routes/instance/session.ts @@ -25,7 +25,7 @@ import { errors } from "../../error" import { lazy } from "@/util/lazy" import { zodObject } from "@/util/effect-zod" import { Bus } from "@/bus" -import { NamedError } from "@opencode-ai/shared/util/error" +import { NamedError } from "@opencode-ai/core/util/error" import { jsonRequest, runRequest } from "./trace" const log = Log.create({ service: "server" }) diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 122644c1f..a18a55584 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -5,7 +5,7 @@ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/ import { Config } from "@/config" import { InstanceState } from "@/effect" import { Flag } from "@/flag/flag" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { withTransientReadRetry } from "@/util/effect-http-client" import { Global } from "../global" import { Log } from "../util" diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index d04645b73..8a2d352a5 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -1,7 +1,7 @@ import { BusEvent } from "@/bus/bus-event" import { SessionID, MessageID, PartID } from "./schema" import z from "zod" -import { NamedError } from "@opencode-ai/shared/util/error" +import { NamedError } from "@opencode-ai/core/util/error" import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai" import { LSP } from "../lsp" import { Snapshot } from "@/snapshot" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 3d07a96ec..8e227e602 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -33,14 +33,14 @@ import { Command } from "../command" import { pathToFileURL, fileURLToPath } from "url" import { ConfigMarkdown } from "../config" import { SessionSummary } from "./summary" -import { NamedError } from "@opencode-ai/shared/util/error" +import { NamedError } from "@opencode-ai/core/util/error" import { SessionProcessor } from "./processor" import { Tool } from "@/tool" import { Permission } from "@/permission" import { SessionStatus } from "./status" import { LLM } from "./llm" import { Shell } from "@/shell/shell" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Truncate } from "@/tool" import { decodeDataUrl } from "@/util/data-url" import { Process } from "@/util" diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 12fd4d345..e81e19737 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -1,4 +1,4 @@ -import type { NamedError } from "@opencode-ai/shared/util/error" +import type { NamedError } from "@opencode-ai/core/util/error" import { Cause, Clock, Duration, Effect, Schedule } from "effect" import { MessageV2 } from "./message-v2" import { iife } from "@/util/iife" diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index f4fe3bf8b..472339b05 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -1,4 +1,4 @@ -import { Slug } from "@opencode-ai/shared/util/slug" +import { Slug } from "@opencode-ai/core/util/slug" import path from "path" import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" diff --git a/packages/opencode/src/skill/discovery.ts b/packages/opencode/src/skill/discovery.ts index debd68dd3..e620de983 100644 --- a/packages/opencode/src/skill/discovery.ts +++ b/packages/opencode/src/skill/discovery.ts @@ -2,7 +2,7 @@ import { NodePath } from "@effect/platform-node" import { Effect, Layer, Path, Schema, Context } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { withTransientReadRetry } from "@/util/effect-http-client" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Global } from "../global" import { Log } from "../util" diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index dd5cc4e5d..e5282e250 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -3,17 +3,17 @@ import path from "path" import { pathToFileURL } from "url" import z from "zod" import { Effect, Layer, Context } from "effect" -import { NamedError } from "@opencode-ai/shared/util/error" +import { NamedError } from "@opencode-ai/core/util/error" import type { Agent } from "@/agent/agent" import { Bus } from "@/bus" import { InstanceState } from "@/effect" import { Flag } from "@/flag/flag" import { Global } from "@/global" import { Permission } from "@/permission" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Config } from "../config" import { ConfigMarkdown } from "../config" -import { Glob } from "@opencode-ai/shared/util/glob" +import { Glob } from "@opencode-ai/core/util/glob" import { Log } from "../util" import { Discovery } from "./discovery" diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index ddc4cb29e..50804ca2b 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -5,8 +5,8 @@ import path from "path" import z from "zod" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import { InstanceState } from "@/effect" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" -import { Hash } from "@opencode-ai/shared/util/hash" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Hash } from "@opencode-ai/core/util/hash" import { Config } from "../config" import { Global } from "../global" import { Log } from "../util" diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index 2c0076452..67f5f1289 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -6,7 +6,7 @@ import { LocalContext } from "../util" import { lazy } from "../util/lazy" import { Global } from "../global" import { Log } from "../util" -import { NamedError } from "@opencode-ai/shared/util/error" +import { NamedError } from "@opencode-ai/core/util/error" import z from "zod" import path from "path" import { readFileSync, readdirSync, existsSync } from "fs" diff --git a/packages/opencode/src/storage/json-migration.ts b/packages/opencode/src/storage/json-migration.ts index 05588db0f..20ca3ff53 100644 --- a/packages/opencode/src/storage/json-migration.ts +++ b/packages/opencode/src/storage/json-migration.ts @@ -8,7 +8,7 @@ import { SessionShareTable } from "../share/share.sql" import path from "path" import { existsSync } from "fs" import { Filesystem } from "../util" -import { Glob } from "@opencode-ai/shared/util/glob" +import { Glob } from "@opencode-ai/core/util/glob" const log = Log.create({ service: "json-migration" }) diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index b1685e689..8f6332677 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -1,9 +1,9 @@ import { Log } from "../util" import path from "path" import { Global } from "../global" -import { NamedError } from "@opencode-ai/shared/util/error" +import { NamedError } from "@opencode-ai/core/util/error" import z from "zod" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Effect, Exit, Layer, Option, RcMap, Schema, Context, TxReentrantLock } from "effect" import { Git } from "@/git" diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 72f24a3f6..9a009189d 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -9,7 +9,7 @@ import { createTwoFilesPatch, diffLines } from "diff" import { assertExternalDirectoryEffect } from "./external-directory" import { trimDiff } from "./edit" import { LSP } from "../lsp" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import DESCRIPTION from "./apply_patch.txt" import { File } from "../file" import { Format } from "../format" diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 0a7e1a6dc..1b8875326 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -9,7 +9,7 @@ import { Instance } from "../project/instance" import { lazy } from "@/util/lazy" import { Language, type Node } from "web-tree-sitter" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { fileURLToPath } from "url" import { Flag } from "@/flag/flag" import { Shell } from "@/shell/shell" diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index cfff5a0a3..04a84a388 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -16,7 +16,7 @@ import { Format } from "../format" import { Instance } from "../project/instance" import { Snapshot } from "@/snapshot" import { assertExternalDirectoryEffect } from "./external-directory" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import * as Bom from "@/util/bom" function normalizeLineEndings(text: string): string { diff --git a/packages/opencode/src/tool/external-directory.ts b/packages/opencode/src/tool/external-directory.ts index 88b73da50..b8def1d75 100644 --- a/packages/opencode/src/tool/external-directory.ts +++ b/packages/opencode/src/tool/external-directory.ts @@ -4,7 +4,7 @@ import { EffectLogger } from "@/effect" import { InstanceState } from "@/effect" import type * as Tool from "./tool" import { Instance } from "../project/instance" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" type Kind = "file" | "directory" diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index aeecfecb7..984c13d41 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -2,7 +2,7 @@ import path from "path" import { Effect, Option, Schema } from "effect" import * as Stream from "effect/Stream" import { InstanceState } from "@/effect" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Ripgrep } from "../file/ripgrep" import { assertExternalDirectoryEffect } from "./external-directory" import DESCRIPTION from "./glob.txt" diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 416005431..844de6753 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -2,7 +2,7 @@ import path from "path" import { Schema } from "effect" import { Effect, Option } from "effect" import { InstanceState } from "@/effect" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Ripgrep } from "../file/ripgrep" import { assertExternalDirectoryEffect } from "./external-directory" import DESCRIPTION from "./grep.txt" diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts index 3bcae426a..bb3b50344 100644 --- a/packages/opencode/src/tool/lsp.ts +++ b/packages/opencode/src/tool/lsp.ts @@ -6,7 +6,7 @@ import DESCRIPTION from "./lsp.txt" import { Instance } from "../project/instance" import { pathToFileURL } from "url" import { assertExternalDirectoryEffect } from "./external-directory" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" const operations = [ "goToDefinition", diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index d0995626c..e89f03109 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -3,7 +3,7 @@ import { createReadStream } from "fs" import * as path from "path" import { createInterface } from "readline" import * as Tool from "./tool" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { LSP } from "../lsp" import DESCRIPTION from "./read.txt" import { Instance } from "../project/instance" diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 539ad6320..629c57965 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -28,7 +28,7 @@ import { Log } from "@/util" import { LspTool } from "./lsp" import * as Truncate from "./truncate" import { ApplyPatchTool } from "./apply_patch" -import { Glob } from "@opencode-ai/shared/util/glob" +import { Glob } from "@opencode-ai/core/util/glob" import path from "path" import { pathToFileURL } from "url" import { Effect, Layer, Context } from "effect" @@ -42,7 +42,7 @@ import { Question } from "../question" import { Todo } from "../session/todo" import { LSP } from "../lsp" import { Instruction } from "../session/instruction" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Bus } from "../bus" import { Agent } from "../agent/agent" import { Skill } from "../skill" diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts index e0d846858..191d96795 100644 --- a/packages/opencode/src/tool/truncate.ts +++ b/packages/opencode/src/tool/truncate.ts @@ -2,7 +2,7 @@ import { NodePath } from "@effect/platform-node" import { Cause, Duration, Effect, Layer, Option, Schedule, Context } from "effect" import path from "path" import type { Agent } from "../agent/agent" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { evaluate } from "@/permission/evaluate" import { Config } from "../config" import { Identifier } from "../id/id" diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index b52f4a164..d977325f1 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -9,7 +9,7 @@ import { Bus } from "../bus" import { File } from "../file" import { FileWatcher } from "../file/watcher" import { Format } from "../format" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Instance } from "../project/instance" import { trimDiff } from "./edit" import { assertExternalDirectoryEffect } from "./external-directory" diff --git a/packages/opencode/src/util/bom.ts b/packages/opencode/src/util/bom.ts index 484228f3d..79de91578 100644 --- a/packages/opencode/src/util/bom.ts +++ b/packages/opencode/src/util/bom.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" const BOM_CODE = 0xfeff const BOM = String.fromCharCode(BOM_CODE) diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index 6c4d45522..6225c80d2 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -4,7 +4,7 @@ import { realpathSync } from "fs" import { dirname, join, relative, resolve as pathResolve, win32 } from "path" import { Readable } from "stream" import { pipeline } from "stream/promises" -import { Glob } from "@opencode-ai/shared/util/glob" +import { Glob } from "@opencode-ai/core/util/glob" // Fast sync version for metadata checks export async function exists(p: string): Promise { diff --git a/packages/opencode/src/util/log.ts b/packages/opencode/src/util/log.ts index 7c1581bfc..e335a8b43 100644 --- a/packages/opencode/src/util/log.ts +++ b/packages/opencode/src/util/log.ts @@ -3,7 +3,7 @@ import fs from "fs/promises" import { createWriteStream } from "fs" import { Global } from "../global" import z from "zod" -import { Glob } from "@opencode-ai/shared/util/glob" +import { Glob } from "@opencode-ai/core/util/glob" export const Level = z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).meta({ ref: "LogLevel", description: "Log level" }) export type Level = z.infer diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index e122fe453..7539e8d58 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -1,5 +1,5 @@ import z from "zod" -import { NamedError } from "@opencode-ai/shared/util/error" +import { NamedError } from "@opencode-ai/core/util/error" import { Global } from "../global" import { Instance } from "../project/instance" import { InstanceBootstrap } from "../project/bootstrap" @@ -8,7 +8,7 @@ import { Database, eq } from "../storage" import { ProjectTable } from "../project/project.sql" import type { ProjectID } from "../project/schema" import { Log } from "../util" -import { Slug } from "@opencode-ai/shared/util/slug" +import { Slug } from "@opencode-ai/core/util/slug" import { errorMessage } from "../util/error" import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" @@ -16,7 +16,7 @@ import { Git } from "@/git" import { Effect, Layer, Path, Schema, Scope, Context, Stream } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { BootstrapRuntime } from "@/effect/bootstrap-runtime" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import { InstanceState } from "@/effect" diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 361ac0b5d..56b8e7acd 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -3,13 +3,13 @@ import { Effect, Layer, Option } from "effect" import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Config, ConfigManaged } from "../../src/config" import { ConfigParse } from "../../src/config/parse" -import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { Instance } from "../../src/project/instance" import { Auth } from "../../src/auth" import { Account } from "../../src/account/account" import { AccessToken, AccountID, OrgID } from "../../src/account/schema" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Env } from "../../src/env" import { provideTmpdirInstance } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture" @@ -895,7 +895,7 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => { }) // Note: deduplication and serialization of npm installs is now handled by the -// shared Npm.Service (via EffectFlock). Those behaviors are tested in the shared +// core Npm.Service (via EffectFlock). Those behaviors are tested in the core // package's npm tests, not here. test("resolves scoped npm plugins in config", async () => { diff --git a/packages/opencode/test/filesystem/filesystem.test.ts b/packages/opencode/test/filesystem/filesystem.test.ts index 0bb4ba583..2d9271e87 100644 --- a/packages/opencode/test/filesystem/filesystem.test.ts +++ b/packages/opencode/test/filesystem/filesystem.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect } from "bun:test" import { Effect, Layer } from "effect" import { NodeFileSystem } from "@effect/platform-node" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { testEffect } from "../lib/effect" import path from "path" diff --git a/packages/opencode/test/fixture/flock-worker.ts b/packages/opencode/test/fixture/flock-worker.ts index 9954d290c..0b9c314c0 100644 --- a/packages/opencode/test/fixture/flock-worker.ts +++ b/packages/opencode/test/fixture/flock-worker.ts @@ -1,5 +1,5 @@ import fs from "fs/promises" -import { Flock } from "@opencode-ai/shared/util/flock" +import { Flock } from "@opencode-ai/core/util/flock" type Msg = { key: string diff --git a/packages/opencode/test/npm.test.ts b/packages/opencode/test/npm.test.ts index b27d668c8..09fa6b351 100644 --- a/packages/opencode/test/npm.test.ts +++ b/packages/opencode/test/npm.test.ts @@ -3,9 +3,9 @@ import path from "path" import { describe, expect, test } from "bun:test" import { Effect, Layer, Stream } from "effect" import { NodeFileSystem } from "@effect/platform-node" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" -import { Global } from "@opencode-ai/shared/global" -import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Global } from "@opencode-ai/core/global" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { Npm } from "../src/npm" import { tmpdir } from "./fixture/fixture" diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 080519a73..c61df3548 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -9,7 +9,7 @@ import { ProjectID } from "../../src/project/schema" import { Effect, Layer, Stream } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" void Log.init({ print: false }) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 911cb4415..451f1d004 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -4,7 +4,7 @@ import { expect } from "bun:test" import { Cause, Effect, Exit, Fiber, Layer } from "effect" import path from "path" import { fileURLToPath } from "url" -import { NamedError } from "@opencode-ai/shared/util/error" +import { NamedError } from "@opencode-ai/core/util/error" import { Agent as AgentSvc } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { Command } from "../../src/command" @@ -21,7 +21,7 @@ import { Todo } from "../../src/session/todo" import { Session } from "../../src/session" import { LLM } from "../../src/session/llm" import { MessageV2 } from "../../src/session/message-v2" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { SessionCompaction } from "../../src/session/compaction" import { SessionSummary } from "../../src/session/summary" import { Instruction } from "../../src/session/instruction" diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 6ca8775f3..aa1a29ec1 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { NamedError } from "@opencode-ai/shared/util/error" +import type { NamedError } from "@opencode-ai/core/util/error" import { APICallError } from "ai" import { setTimeout as sleep } from "node:timers/promises" import { Effect, Schedule } from "effect" diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 651754733..c7e352262 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -51,7 +51,7 @@ import { SessionStatus } from "../../src/session/status" import { Snapshot } from "../../src/snapshot" import { ToolRegistry } from "../../src/tool" import { Truncate } from "../../src/tool" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { Ripgrep } from "../../src/file/ripgrep" import { Format } from "../../src/format" diff --git a/packages/opencode/test/storage/storage.test.ts b/packages/opencode/test/storage/storage.test.ts index c35244bb7..0587b9dd6 100644 --- a/packages/opencode/test/storage/storage.test.ts +++ b/packages/opencode/test/storage/storage.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import path from "path" import { Effect, Exit, Layer } from "effect" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { Git } from "../../src/git" import { Global } from "../../src/global" diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index fa8843213..f311b3d9b 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -5,7 +5,7 @@ import { Effect, ManagedRuntime, Layer } from "effect" import { ApplyPatchTool } from "../../src/tool/apply_patch" import { Instance } from "../../src/project/instance" import { LSP } from "../../src/lsp" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Format } from "../../src/format" import { Agent } from "../../src/agent/agent" import { Bus } from "../../src/bus" diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index d66cfc3e3..fd35c9aeb 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -12,7 +12,7 @@ import { Agent } from "../../src/agent/agent" import { Truncate } from "../../src/tool" import { SessionID, MessageID } from "../../src/session/schema" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Plugin } from "../../src/plugin" const runtime = ManagedRuntime.make( diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 82e1b4a7f..fb2080591 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -6,7 +6,7 @@ import { EditTool } from "../../src/tool/edit" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" import { LSP } from "../../src/lsp" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Format } from "../../src/format" import { Agent } from "../../src/agent/agent" import { Bus } from "../../src/bus" diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts index 87d35715d..c37e7b35f 100644 --- a/packages/opencode/test/tool/glob.test.ts +++ b/packages/opencode/test/tool/glob.test.ts @@ -5,7 +5,7 @@ import { GlobTool } from "../../src/tool/glob" import { SessionID, MessageID } from "../../src/session/schema" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { Ripgrep } from "../../src/file/ripgrep" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Truncate } from "../../src/tool" import { Agent } from "../../src/agent/agent" import { provideTmpdirInstance } from "../fixture/fixture" diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index 388828f6e..a279574e1 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -8,7 +8,7 @@ import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { Truncate } from "../../src/tool" import { Agent } from "../../src/agent/agent" import { Ripgrep } from "../../src/file/ripgrep" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { testEffect } from "../lib/effect" const it = testEffect( diff --git a/packages/opencode/test/tool/lsp.test.ts b/packages/opencode/test/tool/lsp.test.ts index 57b8fc6e8..b9d48e69a 100644 --- a/packages/opencode/test/tool/lsp.test.ts +++ b/packages/opencode/test/tool/lsp.test.ts @@ -3,7 +3,7 @@ import { Effect, Layer } from "effect" import path from "path" import { Agent } from "../../src/agent/agent" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { LSP } from "../../src/lsp" import { Permission } from "../../src/permission" import { Instance } from "../../src/project/instance" diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 42817d15d..7c3bf51fe 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -3,7 +3,7 @@ import { Cause, Effect, Exit, Layer } from "effect" import path from "path" import { Agent } from "../../src/agent/agent" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { LSP } from "../../src/lsp" import { Permission } from "../../src/permission" import { Instance } from "../../src/project/instance" diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index 36131f959..0714d2d02 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -5,7 +5,7 @@ import fs from "fs/promises" import { WriteTool } from "../../src/tool/write" import { Instance } from "../../src/project/instance" import { LSP } from "../../src/lsp" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Bus } from "../../src/bus" import { Format } from "../../src/format" import { Truncate } from "../../src/tool" diff --git a/packages/opencode/test/util/glob.test.ts b/packages/opencode/test/util/glob.test.ts index e982d5194..4ed2f71f3 100644 --- a/packages/opencode/test/util/glob.test.ts +++ b/packages/opencode/test/util/glob.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect } from "bun:test" import path from "path" import fs from "fs/promises" -import { Glob } from "@opencode-ai/shared/util/glob" +import { Glob } from "@opencode-ai/core/util/glob" import { tmpdir } from "../fixture/fixture" describe("Glob", () => { diff --git a/packages/opencode/test/util/module.test.ts b/packages/opencode/test/util/module.test.ts index 6725149c7..19c7958fc 100644 --- a/packages/opencode/test/util/module.test.ts +++ b/packages/opencode/test/util/module.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import path from "path" -import { Module } from "@opencode-ai/shared/util/module" +import { Module } from "@opencode-ai/core/util/module" import { Filesystem } from "../../src/util" import { tmpdir } from "../fixture/fixture" diff --git a/packages/shared/package.json b/packages/shared/package.json deleted file mode 100644 index beb0d50ed..000000000 --- a/packages/shared/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.25", - "name": "@opencode-ai/shared", - "type": "module", - "license": "MIT", - "private": true, - "scripts": { - "test": "bun test", - "typecheck": "tsgo --noEmit" - }, - "bin": { - "opencode": "./bin/opencode" - }, - "exports": { - "./*": "./src/*.ts" - }, - "imports": {}, - "devDependencies": { - "@tsconfig/bun": "catalog:", - "@types/semver": "catalog:", - "@types/bun": "catalog:", - "@types/npmcli__arborist": "6.3.3" - }, - "dependencies": { - "@effect/platform-node": "catalog:", - "@npmcli/arborist": "catalog:", - "effect": "catalog:", - "glob": "13.0.5", - "mime-types": "3.0.2", - "minimatch": "10.2.5", - "semver": "catalog:", - "xdg-basedir": "5.1.0", - "zod": "catalog:" - }, - "overrides": { - "drizzle-orm": "catalog:" - } -} diff --git a/packages/shared/src/filesystem.ts b/packages/shared/src/filesystem.ts deleted file mode 100644 index 44346be8f..000000000 --- a/packages/shared/src/filesystem.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { NodeFileSystem } from "@effect/platform-node" -import { dirname, join, relative, resolve as pathResolve } from "path" -import { realpathSync } from "fs" -import * as NFS from "fs/promises" -import { lookup } from "mime-types" -import { Effect, FileSystem, Layer, Schema, Context } from "effect" -import type { PlatformError } from "effect/PlatformError" -import { Glob } from "./util/glob" - -export namespace AppFileSystem { - export class FileSystemError extends Schema.TaggedErrorClass()("FileSystemError", { - method: Schema.String, - cause: Schema.optional(Schema.Defect), - }) {} - - export type Error = PlatformError | FileSystemError - - export interface DirEntry { - readonly name: string - readonly type: "file" | "directory" | "symlink" | "other" - } - - export interface Interface extends FileSystem.FileSystem { - readonly isDir: (path: string) => Effect.Effect - readonly isFile: (path: string) => Effect.Effect - readonly existsSafe: (path: string) => Effect.Effect - readonly readJson: (path: string) => Effect.Effect - readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect - readonly ensureDir: (path: string) => Effect.Effect - readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect - readonly readDirectoryEntries: (path: string) => Effect.Effect - readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect - readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect - readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect - readonly glob: (pattern: string, options?: Glob.Options) => Effect.Effect - readonly globMatch: (pattern: string, filepath: string) => boolean - } - - export class Service extends Context.Service()("@opencode/FileSystem") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - - const existsSafe = Effect.fn("FileSystem.existsSafe")(function* (path: string) { - return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false)) - }) - - const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) { - const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void)) - return info?.type === "Directory" - }) - - const isFile = Effect.fn("FileSystem.isFile")(function* (path: string) { - const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void)) - return info?.type === "File" - }) - - const readDirectoryEntries = Effect.fn("FileSystem.readDirectoryEntries")(function* (dirPath: string) { - return yield* Effect.tryPromise({ - try: async () => { - const entries = await NFS.readdir(dirPath, { withFileTypes: true }) - return entries.map( - (e): DirEntry => ({ - name: e.name, - type: e.isDirectory() ? "directory" : e.isSymbolicLink() ? "symlink" : e.isFile() ? "file" : "other", - }), - ) - }, - catch: (cause) => new FileSystemError({ method: "readDirectoryEntries", cause }), - }) - }) - - const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) { - const text = yield* fs.readFileString(path) - return JSON.parse(text) - }) - - const writeJson = Effect.fn("FileSystem.writeJson")(function* (path: string, data: unknown, mode?: number) { - const content = JSON.stringify(data, null, 2) - yield* fs.writeFileString(path, content) - if (mode) yield* fs.chmod(path, mode) - }) - - const ensureDir = Effect.fn("FileSystem.ensureDir")(function* (path: string) { - yield* fs.makeDirectory(path, { recursive: true }) - }) - - const writeWithDirs = Effect.fn("FileSystem.writeWithDirs")(function* ( - path: string, - content: string | Uint8Array, - mode?: number, - ) { - const write = typeof content === "string" ? fs.writeFileString(path, content) : fs.writeFile(path, content) - - yield* write.pipe( - Effect.catchIf( - (e) => e.reason._tag === "NotFound", - () => - Effect.gen(function* () { - yield* fs.makeDirectory(dirname(path), { recursive: true }) - yield* write - }), - ), - ) - if (mode) yield* fs.chmod(path, mode) - }) - - const glob = Effect.fn("FileSystem.glob")(function* (pattern: string, options?: Glob.Options) { - return yield* Effect.tryPromise({ - try: () => Glob.scan(pattern, options), - catch: (cause) => new FileSystemError({ method: "glob", cause }), - }) - }) - - const findUp = Effect.fn("FileSystem.findUp")(function* (target: string, start: string, stop?: string) { - const result: string[] = [] - let current = start - while (true) { - const search = join(current, target) - if (yield* fs.exists(search)) result.push(search) - if (stop === current) break - const parent = dirname(current) - if (parent === current) break - current = parent - } - return result - }) - - const up = Effect.fn("FileSystem.up")(function* (options: { targets: string[]; start: string; stop?: string }) { - const result: string[] = [] - let current = options.start - while (true) { - for (const target of options.targets) { - const search = join(current, target) - if (yield* fs.exists(search)) result.push(search) - } - if (options.stop === current) break - const parent = dirname(current) - if (parent === current) break - current = parent - } - return result - }) - - const globUp = Effect.fn("FileSystem.globUp")(function* (pattern: string, start: string, stop?: string) { - const result: string[] = [] - let current = start - while (true) { - const matches = yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true }).pipe( - Effect.catch(() => Effect.succeed([] as string[])), - ) - result.push(...matches) - if (stop === current) break - const parent = dirname(current) - if (parent === current) break - current = parent - } - return result - }) - - return Service.of({ - ...fs, - existsSafe, - isDir, - isFile, - readDirectoryEntries, - readJson, - writeJson, - ensureDir, - writeWithDirs, - findUp, - up, - globUp, - glob, - globMatch: Glob.match, - }) - }), - ) - - export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer)) - - // Pure helpers that don't need Effect (path manipulation, sync operations) - export function mimeType(p: string): string { - return lookup(p) || "application/octet-stream" - } - - export function normalizePath(p: string): string { - if (process.platform !== "win32") return p - const resolved = pathResolve(windowsPath(p)) - try { - return realpathSync.native(resolved) - } catch { - return resolved - } - } - - export function normalizePathPattern(p: string): string { - if (process.platform !== "win32") return p - if (p === "*") return p - const match = p.match(/^(.*)[\\/]\*$/) - if (!match) return normalizePath(p) - const dir = /^[A-Za-z]:$/.test(match[1]) ? match[1] + "\\" : match[1] - return join(normalizePath(dir), "*") - } - - export function resolve(p: string): string { - const resolved = pathResolve(windowsPath(p)) - try { - return normalizePath(realpathSync(resolved)) - } catch (e: any) { - if (e?.code === "ENOENT") return normalizePath(resolved) - throw e - } - } - - export function windowsPath(p: string): string { - if (process.platform !== "win32") return p - return p - .replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`) - .replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) - .replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) - .replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) - } - - export function overlaps(a: string, b: string) { - const relA = relative(a, b) - const relB = relative(b, a) - return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..") - } - - export function contains(parent: string, child: string) { - return !relative(parent, child).startsWith("..") - } -} diff --git a/packages/shared/src/global.ts b/packages/shared/src/global.ts deleted file mode 100644 index 538cc091b..000000000 --- a/packages/shared/src/global.ts +++ /dev/null @@ -1,42 +0,0 @@ -import path from "path" -import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir" -import os from "os" -import { Context, Effect, Layer } from "effect" - -export namespace Global { - export class Service extends Context.Service()("@opencode/Global") {} - - export interface Interface { - readonly home: string - readonly data: string - readonly cache: string - readonly config: string - readonly state: string - readonly bin: string - readonly log: string - } - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const app = "opencode" - const home = process.env.OPENCODE_TEST_HOME ?? os.homedir() - const data = path.join(xdgData!, app) - const cache = path.join(xdgCache!, app) - const cfg = path.join(xdgConfig!, app) - const state = path.join(xdgState!, app) - const bin = path.join(cache, "bin") - const log = path.join(data, "log") - - return Service.of({ - home, - data, - cache, - config: cfg, - state, - bin, - log, - }) - }), - ) -} diff --git a/packages/shared/src/types.d.ts b/packages/shared/src/types.d.ts deleted file mode 100644 index 60e1639ad..000000000 --- a/packages/shared/src/types.d.ts +++ /dev/null @@ -1,46 +0,0 @@ -declare module "@npmcli/arborist" { - export interface ArboristOptions { - path: string - binLinks?: boolean - progress?: boolean - savePrefix?: string - ignoreScripts?: boolean - [key: string]: unknown - } - - export interface ArboristNode { - name: string - path: string - } - - export interface ArboristEdge { - to?: ArboristNode - } - - export interface ArboristTree { - edgesOut: Map - } - - export interface ReifyOptions { - add?: string[] - save?: boolean - saveType?: "prod" | "dev" | "optional" | "peer" - [key: string]: unknown - } - - export class Arborist { - constructor(options: ArboristOptions) - loadVirtual(): Promise - reify(options?: ReifyOptions): Promise - } -} - -declare var Bun: - | { - file(path: string): { - text(): Promise - json(): Promise - } - write(path: string, content: string | Uint8Array): Promise - } - | undefined diff --git a/packages/shared/src/util/array.ts b/packages/shared/src/util/array.ts deleted file mode 100644 index 1fb8ac69e..000000000 --- a/packages/shared/src/util/array.ts +++ /dev/null @@ -1,10 +0,0 @@ -export function findLast( - items: readonly T[], - predicate: (item: T, index: number, items: readonly T[]) => boolean, -): T | undefined { - for (let i = items.length - 1; i >= 0; i -= 1) { - const item = items[i] - if (predicate(item, i, items)) return item - } - return undefined -} diff --git a/packages/shared/src/util/binary.ts b/packages/shared/src/util/binary.ts deleted file mode 100644 index 3d8f61851..000000000 --- a/packages/shared/src/util/binary.ts +++ /dev/null @@ -1,41 +0,0 @@ -export namespace Binary { - export function search(array: T[], id: string, compare: (item: T) => string): { found: boolean; index: number } { - let left = 0 - let right = array.length - 1 - - while (left <= right) { - const mid = Math.floor((left + right) / 2) - const midId = compare(array[mid]) - - if (midId === id) { - return { found: true, index: mid } - } else if (midId < id) { - left = mid + 1 - } else { - right = mid - 1 - } - } - - return { found: false, index: left } - } - - export function insert(array: T[], item: T, compare: (item: T) => string): T[] { - const id = compare(item) - let left = 0 - let right = array.length - - while (left < right) { - const mid = Math.floor((left + right) / 2) - const midId = compare(array[mid]) - - if (midId < id) { - left = mid + 1 - } else { - right = mid - } - } - - array.splice(left, 0, item) - return array - } -} diff --git a/packages/shared/src/util/effect-flock.ts b/packages/shared/src/util/effect-flock.ts deleted file mode 100644 index 16bcf091b..000000000 --- a/packages/shared/src/util/effect-flock.ts +++ /dev/null @@ -1,283 +0,0 @@ -import path from "path" -import os from "os" -import { randomUUID } from "crypto" -import { Context, Effect, Function, Layer, Option, Schedule, Schema } from "effect" -import type { FileSystem, Scope } from "effect" -import type { PlatformError } from "effect/PlatformError" -import { AppFileSystem } from "../filesystem" -import { Global } from "../global" -import { Hash } from "./hash" - -export namespace EffectFlock { - // --------------------------------------------------------------------------- - // Errors - // --------------------------------------------------------------------------- - - export class LockTimeoutError extends Schema.TaggedErrorClass()("LockTimeoutError", { - key: Schema.String, - }) {} - - export class LockCompromisedError extends Schema.TaggedErrorClass()("LockCompromisedError", { - detail: Schema.String, - }) {} - - class ReleaseError extends Schema.TaggedErrorClass()("ReleaseError", { - detail: Schema.String, - cause: Schema.optional(Schema.Defect), - }) { - override get message() { - return this.detail - } - } - - /** Internal: signals "lock is held, retry later". Never leaks to callers. */ - class NotAcquired extends Schema.TaggedErrorClass()("NotAcquired", {}) {} - - export type LockError = LockTimeoutError | LockCompromisedError - - // --------------------------------------------------------------------------- - // Timing (baked in — no caller ever overrides these) - // --------------------------------------------------------------------------- - - const STALE_MS = 60_000 - const TIMEOUT_MS = 5 * 60_000 - const BASE_DELAY_MS = 100 - const MAX_DELAY_MS = 2_000 - const HEARTBEAT_MS = Math.max(100, Math.floor(STALE_MS / 3)) - - const retrySchedule = Schedule.exponential(BASE_DELAY_MS, 1.7).pipe( - Schedule.either(Schedule.spaced(MAX_DELAY_MS)), - Schedule.jittered, - Schedule.while((meta) => meta.elapsed < TIMEOUT_MS), - ) - - // --------------------------------------------------------------------------- - // Lock metadata schema - // --------------------------------------------------------------------------- - - const LockMetaJson = Schema.fromJsonString( - Schema.Struct({ - token: Schema.String, - pid: Schema.Number, - hostname: Schema.String, - createdAt: Schema.String, - }), - ) - - const decodeMeta = Schema.decodeUnknownSync(LockMetaJson) - const encodeMeta = Schema.encodeSync(LockMetaJson) - - // --------------------------------------------------------------------------- - // Service - // --------------------------------------------------------------------------- - - export interface Interface { - readonly acquire: (key: string, dir?: string) => Effect.Effect - readonly withLock: { - (key: string, dir?: string): (body: Effect.Effect) => Effect.Effect - (body: Effect.Effect, key: string, dir?: string): Effect.Effect - } - } - - export class Service extends Context.Service()("EffectFlock") {} - - // --------------------------------------------------------------------------- - // Layer - // --------------------------------------------------------------------------- - - function wall() { - return performance.timeOrigin + performance.now() - } - - const mtimeMs = (info: FileSystem.File.Info) => Option.getOrElse(info.mtime, () => new Date(0)).getTime() - - const isPathGone = (e: PlatformError) => e.reason._tag === "NotFound" || e.reason._tag === "Unknown" - - export const layer: Layer.Layer = Layer.effect( - Service, - Effect.gen(function* () { - const global = yield* Global.Service - const fs = yield* AppFileSystem.Service - const lockRoot = path.join(global.state, "locks") - const hostname = os.hostname() - const ensuredDirs = new Set() - - // -- helpers (close over fs) -- - - const safeStat = (file: string) => - fs.stat(file).pipe( - Effect.catchIf(isPathGone, () => Effect.void), - Effect.orDie, - ) - - const forceRemove = (target: string) => fs.remove(target, { recursive: true }).pipe(Effect.ignore) - - /** Atomic mkdir — returns true if created, false if already exists, dies on other errors. */ - const atomicMkdir = (dir: string) => - fs.makeDirectory(dir, { mode: 0o700 }).pipe( - Effect.as(true), - Effect.catchIf( - (e) => e.reason._tag === "AlreadyExists", - () => Effect.succeed(false), - ), - Effect.orDie, - ) - - /** Write with exclusive create — compromised error if file already exists. */ - const exclusiveWrite = (filePath: string, content: string, lockDir: string, detail: string) => - fs.writeFileString(filePath, content, { flag: "wx" }).pipe( - Effect.catch(() => - Effect.gen(function* () { - yield* forceRemove(lockDir) - return yield* new LockCompromisedError({ detail }) - }), - ), - ) - - const cleanStaleBreaker = Effect.fnUntraced(function* (breakerPath: string) { - const bs = yield* safeStat(breakerPath) - if (bs && wall() - mtimeMs(bs) > STALE_MS) yield* forceRemove(breakerPath) - return false - }) - - const ensureDir = Effect.fnUntraced(function* (dir: string) { - if (ensuredDirs.has(dir)) return - yield* fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie) - ensuredDirs.add(dir) - }) - - const isStale = Effect.fnUntraced(function* (lockDir: string, heartbeatPath: string, metaPath: string) { - const now = wall() - - const hb = yield* safeStat(heartbeatPath) - if (hb) return now - mtimeMs(hb) > STALE_MS - - const meta = yield* safeStat(metaPath) - if (meta) return now - mtimeMs(meta) > STALE_MS - - const dir = yield* safeStat(lockDir) - if (!dir) return false - - return now - mtimeMs(dir) > STALE_MS - }) - - // -- single lock attempt -- - - type Handle = { token: string; metaPath: string; heartbeatPath: string; lockDir: string } - - const tryAcquireLockDir = (lockDir: string, key: string) => - Effect.gen(function* () { - const token = randomUUID() - const metaPath = path.join(lockDir, "meta.json") - const heartbeatPath = path.join(lockDir, "heartbeat") - - // Atomic mkdir — the POSIX lock primitive - const created = yield* atomicMkdir(lockDir) - - if (!created) { - if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return yield* new NotAcquired() - - // Stale — race for breaker ownership - const breakerPath = lockDir + ".breaker" - - const claimed = yield* fs.makeDirectory(breakerPath, { mode: 0o700 }).pipe( - Effect.as(true), - Effect.catchIf( - (e) => e.reason._tag === "AlreadyExists", - () => cleanStaleBreaker(breakerPath), - ), - Effect.catchIf(isPathGone, () => Effect.succeed(false)), - Effect.orDie, - ) - - if (!claimed) return yield* new NotAcquired() - - // We own the breaker — double-check staleness, nuke, recreate - const recreated = yield* Effect.gen(function* () { - if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return false - yield* forceRemove(lockDir) - return yield* atomicMkdir(lockDir) - }).pipe(Effect.ensuring(forceRemove(breakerPath))) - - if (!recreated) return yield* new NotAcquired() - } - - // We own the lock dir — write heartbeat + meta with exclusive create - yield* exclusiveWrite(heartbeatPath, "", lockDir, "heartbeat already existed") - - const metaJson = encodeMeta({ token, pid: process.pid, hostname, createdAt: new Date().toISOString() }) - yield* exclusiveWrite(metaPath, metaJson, lockDir, "meta.json already existed") - - return { token, metaPath, heartbeatPath, lockDir } satisfies Handle - }).pipe( - Effect.withSpan("EffectFlock.tryAcquire", { - attributes: { key }, - }), - ) - - // -- retry wrapper (preserves Handle type) -- - - const acquireHandle = (lockfile: string, key: string): Effect.Effect => - tryAcquireLockDir(lockfile, key).pipe( - Effect.retry({ - while: (err) => err._tag === "NotAcquired", - schedule: retrySchedule, - }), - Effect.catchTag("NotAcquired", () => Effect.fail(new LockTimeoutError({ key }))), - ) - - // -- release -- - - const release = (handle: Handle) => - Effect.gen(function* () { - const raw = yield* fs.readFileString(handle.metaPath).pipe( - Effect.catch((err) => { - if (isPathGone(err)) return Effect.die(new ReleaseError({ detail: "metadata missing" })) - return Effect.die(err) - }), - ) - - const parsed = yield* Effect.try({ - try: () => decodeMeta(raw), - catch: (cause) => new ReleaseError({ detail: "metadata invalid", cause }), - }).pipe(Effect.orDie) - - if (parsed.token !== handle.token) return yield* Effect.die(new ReleaseError({ detail: "token mismatch" })) - - yield* forceRemove(handle.lockDir) - }) - - // -- build service -- - - const acquire = Effect.fn("EffectFlock.acquire")(function* (key: string, dir?: string) { - const lockDir = dir ?? lockRoot - yield* ensureDir(lockDir) - - const lockfile = path.join(lockDir, Hash.fast(key) + ".lock") - - // acquireRelease: acquire is uninterruptible, release is guaranteed - const handle = yield* Effect.acquireRelease(acquireHandle(lockfile, key), (handle) => release(handle)) - - // Heartbeat fiber — scoped, so it's interrupted before release runs - yield* fs - .utimes(handle.heartbeatPath, new Date(), new Date()) - .pipe(Effect.ignore, Effect.repeat(Schedule.spaced(HEARTBEAT_MS)), Effect.forkScoped) - }) - - const withLock: Interface["withLock"] = Function.dual( - (args) => Effect.isEffect(args[0]), - (body: Effect.Effect, key: string, dir?: string): Effect.Effect => - Effect.scoped( - Effect.gen(function* () { - yield* acquire(key, dir) - return yield* body - }), - ), - ) - - return Service.of({ acquire, withLock }) - }), - ) - - export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.layer)) -} diff --git a/packages/shared/src/util/encode.ts b/packages/shared/src/util/encode.ts deleted file mode 100644 index e4c6e70ac..000000000 --- a/packages/shared/src/util/encode.ts +++ /dev/null @@ -1,51 +0,0 @@ -export function base64Encode(value: string) { - const bytes = new TextEncoder().encode(value) - const binary = Array.from(bytes, (b) => String.fromCharCode(b)).join("") - return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "") -} - -export function base64Decode(value: string) { - const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/")) - const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0)) - return new TextDecoder().decode(bytes) -} - -export async function hash(content: string, algorithm = "SHA-256"): Promise { - const encoder = new TextEncoder() - const data = encoder.encode(content) - const hashBuffer = await crypto.subtle.digest(algorithm, data) - const hashArray = Array.from(new Uint8Array(hashBuffer)) - const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("") - return hashHex -} - -export function checksum(content: string): string | undefined { - if (!content) return undefined - let hash = 0x811c9dc5 - for (let i = 0; i < content.length; i++) { - hash ^= content.charCodeAt(i) - hash = Math.imul(hash, 0x01000193) - } - return (hash >>> 0).toString(36) -} - -export function sampledChecksum(content: string, limit = 500_000): string | undefined { - if (!content) return undefined - if (content.length <= limit) return checksum(content) - - const size = 4096 - const points = [ - 0, - Math.floor(content.length * 0.25), - Math.floor(content.length * 0.5), - Math.floor(content.length * 0.75), - content.length - size, - ] - const hashes = points - .map((point) => { - const start = Math.max(0, Math.min(content.length - size, point - Math.floor(size / 2))) - return checksum(content.slice(start, start + size)) ?? "" - }) - .join(":") - return `${content.length}:${hashes}` -} diff --git a/packages/shared/src/util/error.ts b/packages/shared/src/util/error.ts deleted file mode 100644 index 9d3b7c661..000000000 --- a/packages/shared/src/util/error.ts +++ /dev/null @@ -1,60 +0,0 @@ -import z from "zod" - -export abstract class NamedError extends Error { - abstract schema(): z.core.$ZodType - abstract toObject(): { name: string; data: any } - - static hasName(error: unknown, name: string): boolean { - return ( - typeof error === "object" && error !== null && "name" in error && (error as Record).name === name - ) - } - - static create(name: Name, data: Data) { - const schema = z - .object({ - name: z.literal(name), - data, - }) - .meta({ - ref: name, - }) - const result = class extends NamedError { - public static readonly Schema = schema - - public override readonly name = name as Name - - constructor( - public readonly data: z.input, - options?: ErrorOptions, - ) { - super(name, options) - this.name = name - } - - static isInstance(input: any): input is InstanceType { - return typeof input === "object" && "name" in input && input.name === name - } - - schema() { - return schema - } - - toObject() { - return { - name: name, - data: this.data, - } - } - } - Object.defineProperty(result, "name", { value: name }) - return result - } - - public static readonly Unknown = NamedError.create( - "UnknownError", - z.object({ - message: z.string(), - }), - ) -} diff --git a/packages/shared/src/util/flock.ts b/packages/shared/src/util/flock.ts deleted file mode 100644 index 958bd9fd1..000000000 --- a/packages/shared/src/util/flock.ts +++ /dev/null @@ -1,358 +0,0 @@ -import path from "path" -import os from "os" -import { randomBytes, randomUUID } from "crypto" -import { mkdir, readFile, rm, stat, utimes, writeFile } from "fs/promises" -import { Hash } from "./hash" -import { Effect } from "effect" - -export type FlockGlobal = { - state: string -} - -export namespace Flock { - let global: FlockGlobal | undefined - - export function setGlobal(g: FlockGlobal) { - global = g - } - - const root = () => { - if (!global) throw new Error("Flock global not set") - return path.join(global.state, "locks") - } - - // Defaults for callers that do not provide timing options. - const defaultOpts = { - staleMs: 60_000, - timeoutMs: 5 * 60_000, - baseDelayMs: 100, - maxDelayMs: 2_000, - } - - export interface WaitEvent { - key: string - attempt: number - delay: number - waited: number - } - - export type Wait = (input: WaitEvent) => void | Promise - - export interface Options { - dir?: string - signal?: AbortSignal - staleMs?: number - timeoutMs?: number - baseDelayMs?: number - maxDelayMs?: number - onWait?: Wait - } - - type Opts = { - staleMs: number - timeoutMs: number - baseDelayMs: number - maxDelayMs: number - } - - type Owned = { - acquired: true - startHeartbeat: (intervalMs?: number) => void - release: () => Promise - } - - export interface Lease { - release: () => Promise - [Symbol.asyncDispose]: () => Promise - } - - function code(err: unknown) { - if (typeof err !== "object" || err === null || !("code" in err)) return - const value = err.code - if (typeof value !== "string") return - return value - } - - function sleep(ms: number, signal?: AbortSignal) { - return new Promise((resolve, reject) => { - if (signal?.aborted) { - reject(signal.reason ?? new Error("Aborted")) - return - } - - let timer: NodeJS.Timeout | undefined - - const done = () => { - signal?.removeEventListener("abort", abort) - resolve() - } - - const abort = () => { - if (timer) { - clearTimeout(timer) - } - signal?.removeEventListener("abort", abort) - reject(signal?.reason ?? new Error("Aborted")) - } - - signal?.addEventListener("abort", abort, { once: true }) - timer = setTimeout(done, ms) - }) - } - - function jitter(ms: number) { - const j = Math.floor(ms * 0.3) - const d = Math.floor(Math.random() * (2 * j + 1)) - j - return Math.max(0, ms + d) - } - - function mono() { - return performance.now() - } - - function wall() { - return performance.timeOrigin + mono() - } - - async function stats(file: string) { - try { - return await stat(file) - } catch (err) { - const errCode = code(err) - if (errCode === "ENOENT" || errCode === "ENOTDIR") return - throw err - } - } - - async function stale(lockDir: string, heartbeatPath: string, metaPath: string, staleMs: number) { - // Stale detection allows automatic recovery after crashed owners. - const now = wall() - const heartbeat = await stats(heartbeatPath) - if (heartbeat) { - return now - heartbeat.mtimeMs > staleMs - } - - const meta = await stats(metaPath) - if (meta) { - return now - meta.mtimeMs > staleMs - } - - const dir = await stats(lockDir) - if (!dir) { - return false - } - - return now - dir.mtimeMs > staleMs - } - - async function tryAcquireLockDir(lockDir: string, opts: Opts): Promise { - const token = randomUUID?.() ?? randomBytes(16).toString("hex") - const metaPath = path.join(lockDir, "meta.json") - const heartbeatPath = path.join(lockDir, "heartbeat") - - try { - await mkdir(lockDir, { mode: 0o700 }) - } catch (err) { - if (code(err) !== "EEXIST") { - throw err - } - - if (!(await stale(lockDir, heartbeatPath, metaPath, opts.staleMs))) { - return { acquired: false } - } - - const breakerPath = lockDir + ".breaker" - try { - await mkdir(breakerPath, { mode: 0o700 }) - } catch (claimErr) { - const errCode = code(claimErr) - if (errCode === "EEXIST") { - const breaker = await stats(breakerPath) - if (breaker && wall() - breaker.mtimeMs > opts.staleMs) { - await rm(breakerPath, { recursive: true, force: true }).catch(() => undefined) - } - return { acquired: false } - } - - if (errCode === "ENOENT" || errCode === "ENOTDIR") { - return { acquired: false } - } - - throw claimErr - } - - try { - // Breaker ownership ensures only one contender performs stale cleanup. - if (!(await stale(lockDir, heartbeatPath, metaPath, opts.staleMs))) { - return { acquired: false } - } - - await rm(lockDir, { recursive: true, force: true }) - - try { - await mkdir(lockDir, { mode: 0o700 }) - } catch (retryErr) { - const errCode = code(retryErr) - if (errCode === "EEXIST" || errCode === "ENOTEMPTY") { - return { acquired: false } - } - throw retryErr - } - } finally { - await rm(breakerPath, { recursive: true, force: true }).catch(() => undefined) - } - } - - const meta = { - token, - pid: process.pid, - hostname: os.hostname(), - createdAt: new Date().toISOString(), - } - - await writeFile(heartbeatPath, "", { flag: "wx" }).catch(async () => { - await rm(lockDir, { recursive: true, force: true }) - throw new Error("Lock acquired but heartbeat already existed (possible compromise).") - }) - - await writeFile(metaPath, JSON.stringify(meta, null, 2), { flag: "wx" }).catch(async () => { - await rm(lockDir, { recursive: true, force: true }) - throw new Error("Lock acquired but meta.json already existed (possible compromise).") - }) - - let timer: NodeJS.Timeout | undefined - - const startHeartbeat = (intervalMs = Math.max(100, Math.floor(opts.staleMs / 3))) => { - if (timer) return - // Heartbeat prevents long critical sections from being evicted as stale. - timer = setInterval(() => { - const t = new Date() - void utimes(heartbeatPath, t, t).catch(() => undefined) - }, intervalMs) - timer.unref?.() - } - - const release = async () => { - if (timer) { - clearInterval(timer) - timer = undefined - } - - const current = await readFile(metaPath, "utf8") - .then((raw) => { - const parsed = JSON.parse(raw) - if (!parsed || typeof parsed !== "object") return {} - return { - token: "token" in parsed && typeof parsed.token === "string" ? parsed.token : undefined, - } - }) - .catch((err) => { - const errCode = code(err) - if (errCode === "ENOENT" || errCode === "ENOTDIR") { - throw new Error("Refusing to release: lock is compromised (metadata missing).") - } - if (err instanceof SyntaxError) { - throw new Error("Refusing to release: lock is compromised (metadata invalid).") - } - throw err - }) - // Token check prevents deleting a lock that was re-acquired by another process. - if (current.token !== token) { - throw new Error("Refusing to release: lock token mismatch (not the owner).") - } - - await rm(lockDir, { recursive: true, force: true }) - } - - return { - acquired: true, - startHeartbeat, - release, - } - } - - async function acquireLockDir( - lockDir: string, - input: { key: string; onWait?: Wait; signal?: AbortSignal }, - opts: Opts, - ) { - const stop = mono() + opts.timeoutMs - let attempt = 0 - let waited = 0 - let delay = opts.baseDelayMs - - while (true) { - input.signal?.throwIfAborted() - - const res = await tryAcquireLockDir(lockDir, opts) - if (res.acquired) { - return res - } - - if (mono() > stop) { - throw new Error(`Timed out waiting for lock: ${input.key}`) - } - - attempt += 1 - const ms = jitter(delay) - await input.onWait?.({ - key: input.key, - attempt, - delay: ms, - waited, - }) - await sleep(ms, input.signal) - waited += ms - delay = Math.min(opts.maxDelayMs, Math.floor(delay * 1.7)) - } - } - - export async function acquire(key: string, input: Options = {}): Promise { - input.signal?.throwIfAborted() - const cfg: Opts = { - staleMs: input.staleMs ?? defaultOpts.staleMs, - timeoutMs: input.timeoutMs ?? defaultOpts.timeoutMs, - baseDelayMs: input.baseDelayMs ?? defaultOpts.baseDelayMs, - maxDelayMs: input.maxDelayMs ?? defaultOpts.maxDelayMs, - } - const dir = input.dir ?? root() - - await mkdir(dir, { recursive: true }) - const lockfile = path.join(dir, Hash.fast(key) + ".lock") - const lock = await acquireLockDir( - lockfile, - { - key, - onWait: input.onWait, - signal: input.signal, - }, - cfg, - ) - lock.startHeartbeat() - - const release = () => lock.release() - return { - release, - [Symbol.asyncDispose]() { - return release() - }, - } - } - - export async function withLock(key: string, fn: () => Promise, input: Options = {}) { - await using _ = await acquire(key, input) - input.signal?.throwIfAborted() - return await fn() - } - - export const effect = Effect.fn("Flock.effect")(function* (key: string, input: Options = {}) { - return yield* Effect.acquireRelease( - Effect.promise((signal) => Flock.acquire(key, { ...input, signal })).pipe( - Effect.withSpan("Flock.acquire", { - attributes: { key }, - }), - ), - (lock) => Effect.promise(() => lock.release()).pipe(Effect.withSpan("Flock.release")), - ).pipe(Effect.asVoid) - }) -} diff --git a/packages/shared/src/util/fn.ts b/packages/shared/src/util/fn.ts deleted file mode 100644 index 9efe4622f..000000000 --- a/packages/shared/src/util/fn.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { z } from "zod" - -export function fn(schema: T, cb: (input: z.infer) => Result) { - const result = (input: z.infer) => { - const parsed = schema.parse(input) - return cb(parsed) - } - result.force = (input: z.infer) => cb(input) - result.schema = schema - return result -} diff --git a/packages/shared/src/util/glob.ts b/packages/shared/src/util/glob.ts deleted file mode 100644 index febf062da..000000000 --- a/packages/shared/src/util/glob.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { glob, globSync, type GlobOptions } from "glob" -import { minimatch } from "minimatch" - -export namespace Glob { - export interface Options { - cwd?: string - absolute?: boolean - include?: "file" | "all" - dot?: boolean - symlink?: boolean - } - - function toGlobOptions(options: Options): GlobOptions { - return { - cwd: options.cwd, - absolute: options.absolute, - dot: options.dot, - follow: options.symlink ?? false, - nodir: options.include !== "all", - } - } - - export async function scan(pattern: string, options: Options = {}): Promise { - return glob(pattern, toGlobOptions(options)) as Promise - } - - export function scanSync(pattern: string, options: Options = {}): string[] { - return globSync(pattern, toGlobOptions(options)) as string[] - } - - export function match(pattern: string, filepath: string): boolean { - return minimatch(filepath, pattern, { dot: true }) - } -} diff --git a/packages/shared/src/util/hash.ts b/packages/shared/src/util/hash.ts deleted file mode 100644 index 680e0f40b..000000000 --- a/packages/shared/src/util/hash.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createHash } from "crypto" - -export namespace Hash { - export function fast(input: string | Buffer): string { - return createHash("sha1").update(input).digest("hex") - } -} diff --git a/packages/shared/src/util/identifier.ts b/packages/shared/src/util/identifier.ts deleted file mode 100644 index ba28a351b..000000000 --- a/packages/shared/src/util/identifier.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { randomBytes } from "crypto" - -export namespace Identifier { - const LENGTH = 26 - - // State for monotonic ID generation - let lastTimestamp = 0 - let counter = 0 - - export function ascending() { - return create(false) - } - - export function descending() { - return create(true) - } - - function randomBase62(length: number): string { - const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" - let result = "" - const bytes = randomBytes(length) - for (let i = 0; i < length; i++) { - result += chars[bytes[i] % 62] - } - return result - } - - export function create(descending: boolean, timestamp?: number): string { - const currentTimestamp = timestamp ?? Date.now() - - if (currentTimestamp !== lastTimestamp) { - lastTimestamp = currentTimestamp - counter = 0 - } - counter++ - - let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter) - - now = descending ? ~now : now - - const timeBytes = Buffer.alloc(6) - for (let i = 0; i < 6; i++) { - timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff)) - } - - return timeBytes.toString("hex") + randomBase62(LENGTH - 12) - } -} diff --git a/packages/shared/src/util/iife.ts b/packages/shared/src/util/iife.ts deleted file mode 100644 index ca9ae6c10..000000000 --- a/packages/shared/src/util/iife.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function iife(fn: () => T) { - return fn() -} diff --git a/packages/shared/src/util/lazy.ts b/packages/shared/src/util/lazy.ts deleted file mode 100644 index 935ebe0f9..000000000 --- a/packages/shared/src/util/lazy.ts +++ /dev/null @@ -1,11 +0,0 @@ -export function lazy(fn: () => T) { - let value: T | undefined - let loaded = false - - return (): T => { - if (loaded) return value as T - loaded = true - value = fn() - return value as T - } -} diff --git a/packages/shared/src/util/module.ts b/packages/shared/src/util/module.ts deleted file mode 100644 index 6ed3b23d7..000000000 --- a/packages/shared/src/util/module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createRequire } from "node:module" -import path from "node:path" - -export namespace Module { - export function resolve(id: string, dir: string) { - try { - return createRequire(path.join(dir, "package.json")).resolve(id) - } catch {} - } -} diff --git a/packages/shared/src/util/path.ts b/packages/shared/src/util/path.ts deleted file mode 100644 index b87316358..000000000 --- a/packages/shared/src/util/path.ts +++ /dev/null @@ -1,37 +0,0 @@ -export function getFilename(path: string | undefined) { - if (!path) return "" - const trimmed = path.replace(/[/\\]+$/, "") - const parts = trimmed.split(/[/\\]/) - return parts[parts.length - 1] ?? "" -} - -export function getDirectory(path: string | undefined) { - if (!path) return "" - const trimmed = path.replace(/[/\\]+$/, "") - const parts = trimmed.split(/[/\\]/) - return parts.slice(0, parts.length - 1).join("/") + "/" -} - -export function getFileExtension(path: string | undefined) { - if (!path) return "" - const parts = path.split(".") - return parts[parts.length - 1] -} - -export function getFilenameTruncated(path: string | undefined, maxLength: number = 20) { - const filename = getFilename(path) - if (filename.length <= maxLength) return filename - const lastDot = filename.lastIndexOf(".") - const ext = lastDot <= 0 ? "" : filename.slice(lastDot) - const available = maxLength - ext.length - 1 // -1 for ellipsis - if (available <= 0) return filename.slice(0, maxLength - 1) + "…" - return filename.slice(0, available) + "…" + ext -} - -export function truncateMiddle(text: string, maxLength: number = 20) { - if (text.length <= maxLength) return text - const available = maxLength - 1 // -1 for ellipsis - const start = Math.ceil(available / 2) - const end = Math.floor(available / 2) - return text.slice(0, start) + "…" + text.slice(-end) -} diff --git a/packages/shared/src/util/retry.ts b/packages/shared/src/util/retry.ts deleted file mode 100644 index 831d23800..000000000 --- a/packages/shared/src/util/retry.ts +++ /dev/null @@ -1,42 +0,0 @@ -export interface RetryOptions { - attempts?: number - delay?: number - factor?: number - maxDelay?: number - retryIf?: (error: unknown) => boolean -} - -const TRANSIENT_MESSAGES = [ - "load failed", - "network connection was lost", - "network request failed", - "failed to fetch", - "econnreset", - "econnrefused", - "etimedout", - "socket hang up", -] - -function isTransientError(error: unknown): boolean { - if (!error) return false - // oxlint-disable-next-line no-base-to-string -- error is unknown, intentional coercion for message matching - const message = String(error instanceof Error ? error.message : error).toLowerCase() - return TRANSIENT_MESSAGES.some((m) => message.includes(m)) -} - -export async function retry(fn: () => Promise, options: RetryOptions = {}): Promise { - const { attempts = 3, delay = 500, factor = 2, maxDelay = 10000, retryIf = isTransientError } = options - - let lastError: unknown - for (let attempt = 0; attempt < attempts; attempt++) { - try { - return await fn() - } catch (error) { - lastError = error - if (attempt === attempts - 1 || !retryIf(error)) throw error - const wait = Math.min(delay * Math.pow(factor, attempt), maxDelay) - await new Promise((resolve) => setTimeout(resolve, wait)) - } - } - throw lastError -} diff --git a/packages/shared/src/util/slug.ts b/packages/shared/src/util/slug.ts deleted file mode 100644 index 62cf0e57b..000000000 --- a/packages/shared/src/util/slug.ts +++ /dev/null @@ -1,74 +0,0 @@ -export namespace Slug { - const ADJECTIVES = [ - "brave", - "calm", - "clever", - "cosmic", - "crisp", - "curious", - "eager", - "gentle", - "glowing", - "happy", - "hidden", - "jolly", - "kind", - "lucky", - "mighty", - "misty", - "neon", - "nimble", - "playful", - "proud", - "quick", - "quiet", - "shiny", - "silent", - "stellar", - "sunny", - "swift", - "tidy", - "witty", - ] as const - - const NOUNS = [ - "cabin", - "cactus", - "canyon", - "circuit", - "comet", - "eagle", - "engine", - "falcon", - "forest", - "garden", - "harbor", - "island", - "knight", - "lagoon", - "meadow", - "moon", - "mountain", - "nebula", - "orchid", - "otter", - "panda", - "pixel", - "planet", - "river", - "rocket", - "sailor", - "squid", - "star", - "tiger", - "wizard", - "wolf", - ] as const - - export function create() { - return [ - ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)], - NOUNS[Math.floor(Math.random() * NOUNS.length)], - ].join("-") - } -} diff --git a/packages/shared/sst-env.d.ts b/packages/shared/sst-env.d.ts deleted file mode 100644 index 64441936d..000000000 --- a/packages/shared/sst-env.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* This file is auto-generated by SST. Do not edit. */ -/* tslint:disable */ -/* eslint-disable */ -/* deno-fmt-ignore-file */ -/* biome-ignore-all lint: auto-generated */ - -/// - -import "sst" -export {} \ No newline at end of file diff --git a/packages/shared/test/filesystem/filesystem.test.ts b/packages/shared/test/filesystem/filesystem.test.ts deleted file mode 100644 index b49026bcb..000000000 --- a/packages/shared/test/filesystem/filesystem.test.ts +++ /dev/null @@ -1,338 +0,0 @@ -import { describe, test, expect } from "bun:test" -import { Effect, Layer, FileSystem } from "effect" -import { NodeFileSystem } from "@effect/platform-node" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" -import { testEffect } from "../lib/effect" -import path from "path" - -const live = AppFileSystem.layer.pipe(Layer.provideMerge(NodeFileSystem.layer)) -const { effect: it } = testEffect(live) - -describe("AppFileSystem", () => { - describe("isDir", () => { - it( - "returns true for directories", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - expect(yield* fs.isDir(tmp)).toBe(true) - }), - ) - - it( - "returns false for files", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - const file = path.join(tmp, "test.txt") - yield* filesys.writeFileString(file, "hello") - expect(yield* fs.isDir(file)).toBe(false) - }), - ) - - it( - "returns false for non-existent paths", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - expect(yield* fs.isDir("/tmp/nonexistent-" + Math.random())).toBe(false) - }), - ) - }) - - describe("isFile", () => { - it( - "returns true for files", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - const file = path.join(tmp, "test.txt") - yield* filesys.writeFileString(file, "hello") - expect(yield* fs.isFile(file)).toBe(true) - }), - ) - - it( - "returns false for directories", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - expect(yield* fs.isFile(tmp)).toBe(false) - }), - ) - }) - - describe("readJson / writeJson", () => { - it( - "round-trips JSON data", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - const file = path.join(tmp, "data.json") - const data = { name: "test", count: 42, nested: { ok: true } } - - yield* fs.writeJson(file, data) - const result = yield* fs.readJson(file) - - expect(result).toEqual(data) - }), - ) - }) - - describe("ensureDir", () => { - it( - "creates nested directories", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - const nested = path.join(tmp, "a", "b", "c") - - yield* fs.ensureDir(nested) - - const info = yield* filesys.stat(nested) - expect(info.type).toBe("Directory") - }), - ) - - it( - "is idempotent", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - const dir = path.join(tmp, "existing") - yield* filesys.makeDirectory(dir) - - yield* fs.ensureDir(dir) - - const info = yield* filesys.stat(dir) - expect(info.type).toBe("Directory") - }), - ) - }) - - describe("writeWithDirs", () => { - it( - "creates parent directories if missing", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - const file = path.join(tmp, "deep", "nested", "file.txt") - - yield* fs.writeWithDirs(file, "hello") - - expect(yield* filesys.readFileString(file)).toBe("hello") - }), - ) - - it( - "writes directly when parent exists", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - const file = path.join(tmp, "direct.txt") - - yield* fs.writeWithDirs(file, "world") - - expect(yield* filesys.readFileString(file)).toBe("world") - }), - ) - - it( - "writes Uint8Array content", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - const file = path.join(tmp, "binary.bin") - const content = new Uint8Array([0x00, 0x01, 0x02, 0x03]) - - yield* fs.writeWithDirs(file, content) - - const result = yield* filesys.readFile(file) - expect(new Uint8Array(result)).toEqual(content) - }), - ) - }) - - describe("findUp", () => { - it( - "finds target in start directory", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - yield* filesys.writeFileString(path.join(tmp, "target.txt"), "found") - - const result = yield* fs.findUp("target.txt", tmp) - expect(result).toEqual([path.join(tmp, "target.txt")]) - }), - ) - - it( - "finds target in parent directories", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - yield* filesys.writeFileString(path.join(tmp, "marker"), "root") - const child = path.join(tmp, "a", "b") - yield* filesys.makeDirectory(child, { recursive: true }) - - const result = yield* fs.findUp("marker", child, tmp) - expect(result).toEqual([path.join(tmp, "marker")]) - }), - ) - - it( - "returns empty array when not found", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - const result = yield* fs.findUp("nonexistent", tmp, tmp) - expect(result).toEqual([]) - }), - ) - }) - - describe("up", () => { - it( - "finds multiple targets walking up", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - yield* filesys.writeFileString(path.join(tmp, "a.txt"), "a") - yield* filesys.writeFileString(path.join(tmp, "b.txt"), "b") - const child = path.join(tmp, "sub") - yield* filesys.makeDirectory(child) - yield* filesys.writeFileString(path.join(child, "a.txt"), "a-child") - - const result = yield* fs.up({ targets: ["a.txt", "b.txt"], start: child, stop: tmp }) - - expect(result).toContain(path.join(child, "a.txt")) - expect(result).toContain(path.join(tmp, "a.txt")) - expect(result).toContain(path.join(tmp, "b.txt")) - }), - ) - }) - - describe("glob", () => { - it( - "finds files matching pattern", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - yield* filesys.writeFileString(path.join(tmp, "a.ts"), "a") - yield* filesys.writeFileString(path.join(tmp, "b.ts"), "b") - yield* filesys.writeFileString(path.join(tmp, "c.json"), "c") - - const result = yield* fs.glob("*.ts", { cwd: tmp }) - expect(result.sort()).toEqual(["a.ts", "b.ts"]) - }), - ) - - it( - "supports absolute paths", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - yield* filesys.writeFileString(path.join(tmp, "file.txt"), "hello") - - const result = yield* fs.glob("*.txt", { cwd: tmp, absolute: true }) - expect(result).toEqual([path.join(tmp, "file.txt")]) - }), - ) - }) - - describe("globMatch", () => { - it( - "matches patterns", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - expect(fs.globMatch("*.ts", "foo.ts")).toBe(true) - expect(fs.globMatch("*.ts", "foo.json")).toBe(false) - expect(fs.globMatch("src/**", "src/a/b.ts")).toBe(true) - }), - ) - }) - - describe("globUp", () => { - it( - "finds files walking up directories", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - yield* filesys.writeFileString(path.join(tmp, "root.md"), "root") - const child = path.join(tmp, "a", "b") - yield* filesys.makeDirectory(child, { recursive: true }) - yield* filesys.writeFileString(path.join(child, "leaf.md"), "leaf") - - const result = yield* fs.globUp("*.md", child, tmp) - expect(result).toContain(path.join(child, "leaf.md")) - expect(result).toContain(path.join(tmp, "root.md")) - }), - ) - }) - - describe("built-in passthrough", () => { - it( - "exists works", - Effect.gen(function* () { - yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - const file = path.join(tmp, "exists.txt") - yield* filesys.writeFileString(file, "yes") - - expect(yield* filesys.exists(file)).toBe(true) - expect(yield* filesys.exists(file + ".nope")).toBe(false) - }), - ) - - it( - "remove works", - Effect.gen(function* () { - yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - const file = path.join(tmp, "delete-me.txt") - yield* filesys.writeFileString(file, "bye") - - yield* filesys.remove(file) - - expect(yield* filesys.exists(file)).toBe(false) - }), - ) - }) - - describe("pure helpers", () => { - test("mimeType returns correct types", () => { - expect(AppFileSystem.mimeType("file.json")).toBe("application/json") - expect(AppFileSystem.mimeType("image.png")).toBe("image/png") - expect(AppFileSystem.mimeType("unknown.qzx")).toBe("application/octet-stream") - }) - - test("contains checks path containment", () => { - expect(AppFileSystem.contains("/a/b", "/a/b/c")).toBe(true) - expect(AppFileSystem.contains("/a/b", "/a/c")).toBe(false) - }) - - test("overlaps detects overlapping paths", () => { - expect(AppFileSystem.overlaps("/a/b", "/a/b/c")).toBe(true) - expect(AppFileSystem.overlaps("/a/b/c", "/a/b")).toBe(true) - expect(AppFileSystem.overlaps("/a", "/b")).toBe(false) - }) - }) -}) diff --git a/packages/shared/test/fixture/effect-flock-worker.ts b/packages/shared/test/fixture/effect-flock-worker.ts deleted file mode 100644 index c9116c2d5..000000000 --- a/packages/shared/test/fixture/effect-flock-worker.ts +++ /dev/null @@ -1,63 +0,0 @@ -import fs from "fs/promises" -import os from "os" -import { Effect, Layer } from "effect" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" -import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" -import { Global } from "@opencode-ai/shared/global" - -type Msg = { - key: string - dir: string - holdMs?: number - ready?: string - active?: string - done?: string -} - -function sleep(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -const msg: Msg = JSON.parse(process.argv[2]!) - -const testGlobal = Layer.succeed( - Global.Service, - Global.Service.of({ - home: os.homedir(), - data: os.tmpdir(), - cache: os.tmpdir(), - config: os.tmpdir(), - state: os.tmpdir(), - bin: os.tmpdir(), - log: os.tmpdir(), - }), -) - -const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer)) - -async function job() { - if (msg.ready) await fs.writeFile(msg.ready, String(process.pid)) - if (msg.active) await fs.writeFile(msg.active, String(process.pid), { flag: "wx" }) - - try { - if (msg.holdMs && msg.holdMs > 0) await sleep(msg.holdMs) - if (msg.done) await fs.appendFile(msg.done, "1\n") - } finally { - if (msg.active) await fs.rm(msg.active, { force: true }) - } -} - -await Effect.runPromise( - Effect.gen(function* () { - const flock = yield* EffectFlock.Service - yield* flock.withLock( - Effect.promise(() => job()), - msg.key, - msg.dir, - ) - }).pipe(Effect.provide(testLayer)), -).catch((err) => { - const text = err instanceof Error ? (err.stack ?? err.message) : String(err) - process.stderr.write(text) - process.exit(1) -}) diff --git a/packages/shared/test/fixture/flock-worker.ts b/packages/shared/test/fixture/flock-worker.ts deleted file mode 100644 index 9954d290c..000000000 --- a/packages/shared/test/fixture/flock-worker.ts +++ /dev/null @@ -1,72 +0,0 @@ -import fs from "fs/promises" -import { Flock } from "@opencode-ai/shared/util/flock" - -type Msg = { - key: string - dir: string - staleMs?: number - timeoutMs?: number - baseDelayMs?: number - maxDelayMs?: number - holdMs?: number - ready?: string - active?: string - done?: string -} - -function sleep(ms: number) { - return new Promise((resolve) => { - setTimeout(resolve, ms) - }) -} - -function input() { - const raw = process.argv[2] - if (!raw) { - throw new Error("Missing flock worker input") - } - - return JSON.parse(raw) as Msg -} - -async function job(input: Msg) { - if (input.ready) { - await fs.writeFile(input.ready, String(process.pid)) - } - - if (input.active) { - await fs.writeFile(input.active, String(process.pid), { flag: "wx" }) - } - - try { - if (input.holdMs && input.holdMs > 0) { - await sleep(input.holdMs) - } - - if (input.done) { - await fs.appendFile(input.done, "1\n") - } - } finally { - if (input.active) { - await fs.rm(input.active, { force: true }) - } - } -} - -async function main() { - const msg = input() - - await Flock.withLock(msg.key, () => job(msg), { - dir: msg.dir, - staleMs: msg.staleMs, - timeoutMs: msg.timeoutMs, - baseDelayMs: msg.baseDelayMs, - maxDelayMs: msg.maxDelayMs, - }) -} - -await main().catch((err) => { - const text = err instanceof Error ? (err.stack ?? err.message) : String(err) - process.stderr.write(text) - process.exit(1) -}) diff --git a/packages/shared/test/lib/effect.ts b/packages/shared/test/lib/effect.ts deleted file mode 100644 index 131ec5cc6..000000000 --- a/packages/shared/test/lib/effect.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { test, type TestOptions } from "bun:test" -import { Cause, Effect, Exit, Layer } from "effect" -import type * as Scope from "effect/Scope" -import * as TestClock from "effect/testing/TestClock" -import * as TestConsole from "effect/testing/TestConsole" - -type Body = Effect.Effect | (() => Effect.Effect) - -const body = (value: Body) => Effect.suspend(() => (typeof value === "function" ? value() : value)) - -const run = (value: Body, layer: Layer.Layer) => - Effect.gen(function* () { - const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit) - if (Exit.isFailure(exit)) { - for (const err of Cause.prettyErrors(exit.cause)) { - yield* Effect.logError(err) - } - } - return yield* exit - }).pipe(Effect.runPromise) - -const make = (testLayer: Layer.Layer, liveLayer: Layer.Layer) => { - const effect = (name: string, value: Body, opts?: number | TestOptions) => - test(name, () => run(value, testLayer), opts) - - effect.only = (name: string, value: Body, opts?: number | TestOptions) => - test.only(name, () => run(value, testLayer), opts) - - effect.skip = (name: string, value: Body, opts?: number | TestOptions) => - test.skip(name, () => run(value, testLayer), opts) - - const live = (name: string, value: Body, opts?: number | TestOptions) => - test(name, () => run(value, liveLayer), opts) - - live.only = (name: string, value: Body, opts?: number | TestOptions) => - test.only(name, () => run(value, liveLayer), opts) - - live.skip = (name: string, value: Body, opts?: number | TestOptions) => - test.skip(name, () => run(value, liveLayer), opts) - - return { effect, live } -} - -// Test environment with TestClock and TestConsole -const testEnv = Layer.mergeAll(TestConsole.layer, TestClock.layer()) - -// Live environment - uses real clock, but keeps TestConsole for output capture -const liveEnv = TestConsole.layer - -export const it = make(testEnv, liveEnv) - -export const testEffect = (layer: Layer.Layer) => - make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv)) diff --git a/packages/shared/test/util/effect-flock.test.ts b/packages/shared/test/util/effect-flock.test.ts deleted file mode 100644 index bd71e4f02..000000000 --- a/packages/shared/test/util/effect-flock.test.ts +++ /dev/null @@ -1,389 +0,0 @@ -import { describe, expect } from "bun:test" -import { spawn } from "child_process" -import fs from "fs/promises" -import path from "path" -import os from "os" -import { Cause, Effect, Exit, Layer } from "effect" -import { testEffect } from "../lib/effect" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" -import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" -import { Global } from "@opencode-ai/shared/global" -import { Hash } from "@opencode-ai/shared/util/hash" - -function lock(dir: string, key: string) { - return path.join(dir, Hash.fast(key) + ".lock") -} - -function sleep(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -async function exists(file: string) { - return fs - .stat(file) - .then(() => true) - .catch(() => false) -} - -async function readJson(p: string): Promise { - return JSON.parse(await fs.readFile(p, "utf8")) -} - -// --------------------------------------------------------------------------- -// Worker subprocess helpers -// --------------------------------------------------------------------------- - -type Msg = { - key: string - dir: string - holdMs?: number - ready?: string - active?: string - done?: string -} - -const root = path.join(import.meta.dir, "../..") -const worker = path.join(import.meta.dir, "../fixture/effect-flock-worker.ts") - -function run(msg: Msg) { - return new Promise<{ code: number; stdout: Buffer; stderr: Buffer }>((resolve) => { - const proc = spawn(process.execPath, [worker, JSON.stringify(msg)], { cwd: root }) - const stdout: Buffer[] = [] - const stderr: Buffer[] = [] - proc.stdout?.on("data", (data) => stdout.push(Buffer.from(data))) - proc.stderr?.on("data", (data) => stderr.push(Buffer.from(data))) - proc.on("close", (code) => { - resolve({ code: code ?? 1, stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr) }) - }) - }) -} - -function spawnWorker(msg: Msg) { - return spawn(process.execPath, [worker, JSON.stringify(msg)], { - cwd: root, - stdio: ["ignore", "pipe", "pipe"], - }) -} - -function stopWorker(proc: ReturnType) { - if (proc.exitCode !== null || proc.signalCode !== null) return Promise.resolve() - if (process.platform !== "win32" || !proc.pid) { - proc.kill() - return Promise.resolve() - } - return new Promise((resolve) => { - const killProc = spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"]) - killProc.on("close", () => { - proc.kill() - resolve() - }) - }) -} - -async function waitForFile(file: string, timeout = 3_000) { - const stop = Date.now() + timeout - while (Date.now() < stop) { - if (await exists(file)) return - await sleep(20) - } - throw new Error(`Timed out waiting for file: ${file}`) -} - -// --------------------------------------------------------------------------- -// Test layer -// --------------------------------------------------------------------------- - -const testGlobal = Layer.succeed( - Global.Service, - Global.Service.of({ - home: os.homedir(), - data: os.tmpdir(), - cache: os.tmpdir(), - config: os.tmpdir(), - state: os.tmpdir(), - bin: os.tmpdir(), - log: os.tmpdir(), - }), -) - -const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer)) - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe("util.effect-flock", () => { - const it = testEffect(testLayer) - - it.live( - "acquire and release via scoped Effect", - Effect.gen(function* () { - const flock = yield* EffectFlock.Service - const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) - const dir = path.join(tmp, "locks") - const lockDir = lock(dir, "eflock:acquire") - - yield* Effect.scoped(flock.acquire("eflock:acquire", dir)) - - expect(yield* Effect.promise(() => exists(lockDir))).toBe(false) - yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) - }), - ) - - it.live( - "withLock data-first", - Effect.gen(function* () { - const flock = yield* EffectFlock.Service - const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) - const dir = path.join(tmp, "locks") - - let hit = false - yield* flock.withLock( - Effect.sync(() => { - hit = true - }), - "eflock:df", - dir, - ) - expect(hit).toBe(true) - yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) - }), - ) - - it.live( - "withLock pipeable", - Effect.gen(function* () { - const flock = yield* EffectFlock.Service - const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) - const dir = path.join(tmp, "locks") - - let hit = false - yield* Effect.sync(() => { - hit = true - }).pipe(flock.withLock("eflock:pipe", dir)) - expect(hit).toBe(true) - yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) - }), - ) - - it.live( - "writes owner metadata", - Effect.gen(function* () { - const flock = yield* EffectFlock.Service - const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) - const dir = path.join(tmp, "locks") - const key = "eflock:meta" - const file = path.join(lock(dir, key), "meta.json") - - yield* Effect.scoped( - Effect.gen(function* () { - yield* flock.acquire(key, dir) - const json = yield* Effect.promise(() => - readJson<{ token?: unknown; pid?: unknown; hostname?: unknown; createdAt?: unknown }>(file), - ) - expect(typeof json.token).toBe("string") - expect(typeof json.pid).toBe("number") - expect(typeof json.hostname).toBe("string") - expect(typeof json.createdAt).toBe("string") - }), - ) - yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) - }), - ) - - it.live( - "breaks stale lock dirs", - Effect.gen(function* () { - const flock = yield* EffectFlock.Service - const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) - const dir = path.join(tmp, "locks") - const key = "eflock:stale" - const lockDir = lock(dir, key) - - yield* Effect.promise(async () => { - await fs.mkdir(lockDir, { recursive: true }) - const old = new Date(Date.now() - 120_000) - await fs.utimes(lockDir, old, old) - }) - - let hit = false - yield* flock.withLock( - Effect.sync(() => { - hit = true - }), - key, - dir, - ) - expect(hit).toBe(true) - yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) - }), - ) - - it.live( - "recovers from stale breaker", - Effect.gen(function* () { - const flock = yield* EffectFlock.Service - const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) - const dir = path.join(tmp, "locks") - const key = "eflock:stale-breaker" - const lockDir = lock(dir, key) - const breaker = lockDir + ".breaker" - - yield* Effect.promise(async () => { - await fs.mkdir(lockDir, { recursive: true }) - await fs.mkdir(breaker) - const old = new Date(Date.now() - 120_000) - await fs.utimes(lockDir, old, old) - await fs.utimes(breaker, old, old) - }) - - let hit = false - yield* flock.withLock( - Effect.sync(() => { - hit = true - }), - key, - dir, - ) - expect(hit).toBe(true) - expect(yield* Effect.promise(() => exists(breaker))).toBe(false) - yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) - }), - ) - - it.live( - "detects compromise when lock dir removed", - Effect.gen(function* () { - const flock = yield* EffectFlock.Service - const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) - const dir = path.join(tmp, "locks") - const key = "eflock:compromised" - const lockDir = lock(dir, key) - - const result = yield* flock - .withLock( - Effect.promise(() => fs.rm(lockDir, { recursive: true, force: true })), - key, - dir, - ) - .pipe(Effect.exit) - - expect(Exit.isFailure(result)).toBe(true) - expect(Exit.isFailure(result) ? Cause.pretty(result.cause) : "").toContain("missing") - yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) - }), - ) - - it.live( - "detects token mismatch", - Effect.gen(function* () { - const flock = yield* EffectFlock.Service - const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) - const dir = path.join(tmp, "locks") - const key = "eflock:token" - const lockDir = lock(dir, key) - const meta = path.join(lockDir, "meta.json") - - const result = yield* flock - .withLock( - Effect.promise(async () => { - const json = await readJson<{ token?: string }>(meta) - json.token = "tampered" - await fs.writeFile(meta, JSON.stringify(json, null, 2)) - }), - key, - dir, - ) - .pipe(Effect.exit) - - expect(Exit.isFailure(result)).toBe(true) - expect(Exit.isFailure(result) ? Cause.pretty(result.cause) : "").toContain("token mismatch") - expect(yield* Effect.promise(() => exists(lockDir))).toBe(true) - yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) - }), - ) - - it.live( - "fails on unwritable lock roots", - Effect.gen(function* () { - if (process.platform === "win32") return - const flock = yield* EffectFlock.Service - const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) - const dir = path.join(tmp, "locks") - - yield* Effect.promise(async () => { - await fs.mkdir(dir, { recursive: true }) - await fs.chmod(dir, 0o500) - }) - - const result = yield* flock.withLock(Effect.void, "eflock:perm", dir).pipe(Effect.exit) - // oxlint-disable-next-line no-base-to-string -- Exit has a useful toString for test assertions - expect(String(result)).toContain("PermissionDenied") - yield* Effect.promise(() => fs.chmod(dir, 0o700).then(() => fs.rm(tmp, { recursive: true, force: true }))) - }), - ) - - it.live( - "enforces mutual exclusion under process contention", - () => - Effect.promise(async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "eflock-stress-")) - const dir = path.join(tmp, "locks") - const done = path.join(tmp, "done.log") - const active = path.join(tmp, "active") - const n = 16 - - try { - const out = await Promise.all( - Array.from({ length: n }, () => run({ key: "eflock:stress", dir, done, active, holdMs: 30 })), - ) - - expect(out.map((x) => x.code)).toEqual(Array.from({ length: n }, () => 0)) - expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([]) - - const lines = (await fs.readFile(done, "utf8")) - .split("\n") - .map((x) => x.trim()) - .filter(Boolean) - expect(lines.length).toBe(n) - } finally { - await fs.rm(tmp, { recursive: true, force: true }) - } - }), - 60_000, - ) - - it.live( - "recovers after a crashed lock owner", - () => - Effect.promise(async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "eflock-crash-")) - const dir = path.join(tmp, "locks") - const ready = path.join(tmp, "ready") - - const proc = spawnWorker({ key: "eflock:crash", dir, ready, holdMs: 120_000 }) - - try { - await waitForFile(ready, 5_000) - await stopWorker(proc) - await new Promise((resolve) => proc.on("close", resolve)) - - // Backdate lock files so they're past STALE_MS (60s) - const lockDir = lock(dir, "eflock:crash") - const old = new Date(Date.now() - 120_000) - await fs.utimes(lockDir, old, old).catch(() => {}) - await fs.utimes(path.join(lockDir, "heartbeat"), old, old).catch(() => {}) - await fs.utimes(path.join(lockDir, "meta.json"), old, old).catch(() => {}) - - const done = path.join(tmp, "done.log") - const result = await run({ key: "eflock:crash", dir, done, holdMs: 10 }) - expect(result.code).toBe(0) - expect(result.stderr.toString()).toBe("") - } finally { - await stopWorker(proc).catch(() => {}) - await fs.rm(tmp, { recursive: true, force: true }) - } - }), - 30_000, - ) -}) diff --git a/packages/shared/test/util/flock.test.ts b/packages/shared/test/util/flock.test.ts deleted file mode 100644 index f1053dfd2..000000000 --- a/packages/shared/test/util/flock.test.ts +++ /dev/null @@ -1,426 +0,0 @@ -import { describe, expect, test } from "bun:test" -import fs from "fs/promises" -import { spawn } from "child_process" -import path from "path" -import os from "os" -import { Flock } from "@opencode-ai/shared/util/flock" -import { Hash } from "@opencode-ai/shared/util/hash" - -type Msg = { - key: string - dir: string - staleMs?: number - timeoutMs?: number - baseDelayMs?: number - maxDelayMs?: number - holdMs?: number - ready?: string - active?: string - done?: string -} - -const root = path.join(import.meta.dir, "../..") -const worker = path.join(import.meta.dir, "../fixture/flock-worker.ts") - -async function tmpdir() { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "flock-test-")) - return { - path: dir, - async [Symbol.asyncDispose]() { - await fs.rm(dir, { recursive: true, force: true }) - }, - } -} - -function lock(dir: string, key: string) { - return path.join(dir, Hash.fast(key) + ".lock") -} - -function sleep(ms: number) { - return new Promise((resolve) => { - setTimeout(resolve, ms) - }) -} - -async function exists(file: string) { - return fs - .stat(file) - .then(() => true) - .catch(() => false) -} - -async function wait(file: string, timeout = 3_000) { - const stop = Date.now() + timeout - while (Date.now() < stop) { - if (await exists(file)) return - await sleep(20) - } - - throw new Error(`Timed out waiting for file: ${file}`) -} - -function run(msg: Msg) { - return new Promise<{ code: number; stdout: Buffer; stderr: Buffer }>((resolve) => { - const proc = spawn(process.execPath, [worker, JSON.stringify(msg)], { - cwd: root, - }) - - const stdout: Buffer[] = [] - const stderr: Buffer[] = [] - - proc.stdout?.on("data", (data) => stdout.push(Buffer.from(data))) - proc.stderr?.on("data", (data) => stderr.push(Buffer.from(data))) - - proc.on("close", (code) => { - resolve({ - code: code ?? 1, - stdout: Buffer.concat(stdout), - stderr: Buffer.concat(stderr), - }) - }) - }) -} - -function spawnWorker(msg: Msg) { - return spawn(process.execPath, [worker, JSON.stringify(msg)], { - cwd: root, - stdio: ["ignore", "pipe", "pipe"], - }) -} - -function stopWorker(proc: ReturnType) { - if (proc.exitCode !== null || proc.signalCode !== null) return Promise.resolve() - - if (process.platform !== "win32" || !proc.pid) { - proc.kill() - return Promise.resolve() - } - - return new Promise((resolve) => { - const killProc = spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"]) - killProc.on("close", () => { - proc.kill() - resolve() - }) - }) -} - -async function readJson(p: string): Promise { - return JSON.parse(await fs.readFile(p, "utf8")) -} - -describe("util.flock", () => { - test("enforces mutual exclusion under process contention", async () => { - await using tmp = await tmpdir() - const dir = path.join(tmp.path, "locks") - const done = path.join(tmp.path, "done.log") - const active = path.join(tmp.path, "active") - const key = "flock:stress" - const n = 16 - - const out = await Promise.all( - Array.from({ length: n }, () => - run({ - key, - dir, - done, - active, - holdMs: 30, - staleMs: 1_000, - timeoutMs: 15_000, - }), - ), - ) - - expect(out.map((x) => x.code)).toEqual(Array.from({ length: n }, () => 0)) - expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([]) - - const lines = (await fs.readFile(done, "utf8")) - .split("\n") - .map((x) => x.trim()) - .filter(Boolean) - expect(lines.length).toBe(n) - }, 20_000) - - test("times out while waiting when lock is still healthy", async () => { - await using tmp = await tmpdir() - const dir = path.join(tmp.path, "locks") - const key = "flock:timeout" - const ready = path.join(tmp.path, "ready") - const proc = spawnWorker({ - key, - dir, - ready, - holdMs: 20_000, - staleMs: 10_000, - timeoutMs: 30_000, - }) - - try { - await wait(ready, 5_000) - const seen: string[] = [] - const err = await Flock.withLock(key, async () => {}, { - dir, - staleMs: 10_000, - timeoutMs: 1_000, - onWait: (tick) => { - seen.push(tick.key) - }, - }).catch((err) => err) - - expect(err).toBeInstanceOf(Error) - if (!(err instanceof Error)) throw err - expect(err.message).toContain("Timed out waiting for lock") - expect(seen.length).toBeGreaterThan(0) - expect(seen.every((x) => x === key)).toBe(true) - } finally { - await stopWorker(proc).catch(() => undefined) - await new Promise((resolve) => proc.on("close", resolve)) - } - }, 15_000) - - test("recovers after a crashed lock owner", async () => { - await using tmp = await tmpdir() - const dir = path.join(tmp.path, "locks") - const key = "flock:crash" - const ready = path.join(tmp.path, "ready") - const proc = spawnWorker({ - key, - dir, - ready, - holdMs: 20_000, - staleMs: 500, - timeoutMs: 30_000, - }) - - await wait(ready, 5_000) - await stopWorker(proc) - await new Promise((resolve) => proc.on("close", resolve)) - - let hit = false - await Flock.withLock( - key, - async () => { - hit = true - }, - { - dir, - staleMs: 500, - timeoutMs: 8_000, - }, - ) - - expect(hit).toBe(true) - }, 20_000) - - test("breaks stale lock dirs when heartbeat is missing", async () => { - await using tmp = await tmpdir() - const dir = path.join(tmp.path, "locks") - const key = "flock:missing-heartbeat" - const lockDir = lock(dir, key) - - await fs.mkdir(lockDir, { recursive: true }) - const old = new Date(Date.now() - 2_000) - await fs.utimes(lockDir, old, old) - - let hit = false - await Flock.withLock( - key, - async () => { - hit = true - }, - { - dir, - staleMs: 200, - timeoutMs: 3_000, - }, - ) - - expect(hit).toBe(true) - }) - - test("recovers when a stale breaker claim was left behind", async () => { - await using tmp = await tmpdir() - const dir = path.join(tmp.path, "locks") - const key = "flock:stale-breaker" - const lockDir = lock(dir, key) - const breaker = lockDir + ".breaker" - - await fs.mkdir(lockDir, { recursive: true }) - await fs.mkdir(breaker) - - const old = new Date(Date.now() - 2_000) - await fs.utimes(lockDir, old, old) - await fs.utimes(breaker, old, old) - - let hit = false - await Flock.withLock( - key, - async () => { - hit = true - }, - { - dir, - staleMs: 200, - timeoutMs: 3_000, - }, - ) - - expect(hit).toBe(true) - expect(await exists(breaker)).toBe(false) - }) - - test("fails clearly if lock dir is removed while held", async () => { - await using tmp = await tmpdir() - const dir = path.join(tmp.path, "locks") - const key = "flock:compromised" - const lockDir = lock(dir, key) - - const err = await Flock.withLock( - key, - async () => { - await fs.rm(lockDir, { - recursive: true, - force: true, - }) - }, - { - dir, - staleMs: 1_000, - timeoutMs: 3_000, - }, - ).catch((err) => err) - - expect(err).toBeInstanceOf(Error) - if (!(err instanceof Error)) throw err - expect(err.message).toContain("compromised") - - let hit = false - await Flock.withLock( - key, - async () => { - hit = true - }, - { - dir, - staleMs: 200, - timeoutMs: 3_000, - }, - ) - expect(hit).toBe(true) - }) - - test("writes owner metadata while lock is held", async () => { - await using tmp = await tmpdir() - const dir = path.join(tmp.path, "locks") - const key = "flock:meta" - const file = path.join(lock(dir, key), "meta.json") - - await Flock.withLock( - key, - async () => { - const json = await readJson<{ - token?: unknown - pid?: unknown - hostname?: unknown - createdAt?: unknown - }>(file) - - expect(typeof json.token).toBe("string") - expect(typeof json.pid).toBe("number") - expect(typeof json.hostname).toBe("string") - expect(typeof json.createdAt).toBe("string") - }, - { - dir, - staleMs: 1_000, - timeoutMs: 3_000, - }, - ) - }) - - test("supports acquire with await using", async () => { - await using tmp = await tmpdir() - const dir = path.join(tmp.path, "locks") - const key = "flock:acquire" - const lockDir = lock(dir, key) - - { - await using _ = await Flock.acquire(key, { - dir, - staleMs: 1_000, - timeoutMs: 3_000, - }) - expect(await exists(lockDir)).toBe(true) - } - - expect(await exists(lockDir)).toBe(false) - }) - - test("refuses token mismatch release and recovers from stale", async () => { - await using tmp = await tmpdir() - const dir = path.join(tmp.path, "locks") - const key = "flock:token" - const lockDir = lock(dir, key) - const meta = path.join(lockDir, "meta.json") - - const err = await Flock.withLock( - key, - async () => { - const json = await readJson<{ token?: string }>(meta) - json.token = "tampered" - await fs.writeFile(meta, JSON.stringify(json, null, 2)) - }, - { - dir, - staleMs: 500, - timeoutMs: 3_000, - }, - ).catch((err) => err) - - expect(err).toBeInstanceOf(Error) - if (!(err instanceof Error)) throw err - expect(err.message).toContain("token mismatch") - expect(await exists(lockDir)).toBe(true) - - let hit = false - await Flock.withLock( - key, - async () => { - hit = true - }, - { - dir, - staleMs: 500, - timeoutMs: 6_000, - }, - ) - expect(hit).toBe(true) - }) - - test("fails clearly on unwritable lock roots", async () => { - if (process.platform === "win32") return - - await using tmp = await tmpdir() - const dir = path.join(tmp.path, "locks") - const key = "flock:perm" - - await fs.mkdir(dir, { recursive: true }) - await fs.chmod(dir, 0o500) - - try { - const err = await Flock.withLock(key, async () => {}, { - dir, - staleMs: 100, - timeoutMs: 500, - }).catch((err) => err) - - expect(err).toBeInstanceOf(Error) - if (!(err instanceof Error)) throw err - const text = err.message - expect(text.includes("EACCES") || text.includes("EPERM")).toBe(true) - } finally { - await fs.chmod(dir, 0o700) - } - }) -}) diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json deleted file mode 100644 index d7745d755..000000000 --- a/packages/shared/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "@tsconfig/bun/tsconfig.json", - "compilerOptions": { - "noUncheckedIndexedAccess": false, - "plugins": [ - { - "name": "@effect/language-service", - "transform": "@effect/language-service/transform", - "namespaceImportPackages": ["effect", "@effect/*"] - } - ] - } -} diff --git a/packages/ui/package.json b/packages/ui/package.json index 9feb8c035..da7a0f673 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -44,7 +44,7 @@ "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", - "@opencode-ai/shared": "workspace:*", + "@opencode-ai/core": "workspace:*", "@pierre/diffs": "catalog:", "@shikijs/transformers": "3.9.2", "@solid-primitives/bounds": "0.1.3", diff --git a/packages/ui/src/components/file.tsx b/packages/ui/src/components/file.tsx index 633b23b70..97d4d69f7 100644 --- a/packages/ui/src/components/file.tsx +++ b/packages/ui/src/components/file.tsx @@ -1,4 +1,4 @@ -import { sampledChecksum } from "@opencode-ai/shared/util/encode" +import { sampledChecksum } from "@opencode-ai/core/util/encode" import { DEFAULT_VIRTUAL_FILE_METRICS, type DiffLineAnnotation, diff --git a/packages/ui/src/components/line-comment.tsx b/packages/ui/src/components/line-comment.tsx index e20da5a8d..e5a7af9cb 100644 --- a/packages/ui/src/components/line-comment.tsx +++ b/packages/ui/src/components/line-comment.tsx @@ -1,5 +1,5 @@ import { useFilteredList } from "@opencode-ai/ui/hooks" -import { getDirectory, getFilename } from "@opencode-ai/shared/util/path" +import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { createSignal, For, onMount, Show, splitProps, type JSX } from "solid-js" import { Button } from "./button" import { FileIcon } from "./file-icon" diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 28653512e..56e2d9d70 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -2,7 +2,7 @@ import { useMarked } from "../context/marked" import { useI18n } from "../context/i18n" import DOMPurify from "dompurify" import morphdom from "morphdom" -import { checksum } from "@opencode-ai/shared/util/encode" +import { checksum } from "@opencode-ai/core/util/encode" import { ComponentProps, createEffect, createResource, createSignal, onCleanup, splitProps } from "solid-js" import { isServer } from "solid-js/web" import { stream } from "./markdown-stream" diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 9c0c90c00..013272205 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -45,8 +45,8 @@ import { Checkbox } from "./checkbox" import { DiffChanges } from "./diff-changes" import { Markdown } from "./markdown" import { ImagePreview } from "./image-preview" -import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/shared/util/path" -import { checksum } from "@opencode-ai/shared/util/encode" +import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/core/util/path" +import { checksum } from "@opencode-ai/core/util/encode" import { Tooltip } from "./tooltip" import { IconButton } from "./icon-button" import { Spinner } from "./spinner" diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 94bca6727..949402f43 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -11,8 +11,8 @@ import { Tooltip } from "./tooltip" import { ScrollView } from "./scroll-view" import { useFileComponent } from "../context/file" import { useI18n } from "../context/i18n" -import { getDirectory, getFilename } from "@opencode-ai/shared/util/path" -import { checksum } from "@opencode-ai/shared/util/encode" +import { getDirectory, getFilename } from "@opencode-ai/core/util/path" +import { checksum } from "@opencode-ai/core/util/encode" import { createEffect, createMemo, For, Match, onCleanup, Show, Switch, untrack, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { type FileContent, type SnapshotFileDiff, type VcsFileDiff } from "@opencode-ai/sdk/v2" diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 61123b180..b35f718ef 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -8,8 +8,8 @@ import type { SessionStatus } from "@opencode-ai/sdk/v2" import { useData } from "../context" import { useFileComponent } from "../context/file" -import { Binary } from "@opencode-ai/shared/util/binary" -import { getDirectory, getFilename } from "@opencode-ai/shared/util/path" +import { Binary } from "@opencode-ai/core/util/binary" +import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js" import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web" -- cgit v1.2.3