From a800583aeaf6500d7bcd09e69ea6e8c8225600a1 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 18 Mar 2026 13:34:36 -0400 Subject: refactor(effect): unify service namespaces and align naming (#18093) --- packages/opencode/AGENTS.md | 76 +- packages/opencode/specs/effect-migration.md | 144 ++++ packages/opencode/src/account/effect.ts | 354 +++++++++ packages/opencode/src/account/index.ts | 14 +- packages/opencode/src/account/service.ts | 359 --------- packages/opencode/src/auth/effect.ts | 95 +++ packages/opencode/src/auth/index.ts | 8 +- packages/opencode/src/auth/service.ts | 101 --- packages/opencode/src/cli/cmd/account.ts | 10 +- packages/opencode/src/effect/instances.ts | 64 +- packages/opencode/src/effect/runtime.ts | 8 +- packages/opencode/src/file/index.ts | 811 ++++++++++----------- packages/opencode/src/file/time.ts | 157 ++-- packages/opencode/src/file/watcher.ts | 118 ++- packages/opencode/src/format/index.ts | 146 ++-- packages/opencode/src/permission/index.ts | 277 ++++++- packages/opencode/src/permission/service.ts | 244 ------- packages/opencode/src/project/bootstrap.ts | 9 +- packages/opencode/src/project/vcs.ts | 71 +- packages/opencode/src/provider/auth-service.ts | 230 ------ packages/opencode/src/provider/auth.ts | 231 +++++- packages/opencode/src/question/index.ts | 192 ++++- packages/opencode/src/question/service.ts | 172 ----- packages/opencode/src/server/server.ts | 4 +- packages/opencode/src/skill/discovery.ts | 195 ++--- packages/opencode/src/skill/skill.ts | 402 +++++----- packages/opencode/src/snapshot/index.ts | 299 ++++---- packages/opencode/src/tool/truncate-effect.ts | 12 +- packages/opencode/test/account/service.test.ts | 14 +- packages/opencode/test/file/watcher.test.ts | 9 +- packages/opencode/test/format/format.test.ts | 23 +- packages/opencode/test/permission/next.test.ts | 4 +- .../opencode/test/plugin/auth-override.test.ts | 3 +- packages/opencode/test/project/vcs.test.ts | 32 +- .../opencode/test/server/project-init-git.test.ts | 3 + packages/opencode/test/skill/discovery.test.ts | 4 +- packages/opencode/test/snapshot/snapshot.test.ts | 34 + 37 files changed, 2452 insertions(+), 2477 deletions(-) create mode 100644 packages/opencode/specs/effect-migration.md create mode 100644 packages/opencode/src/account/effect.ts delete mode 100644 packages/opencode/src/account/service.ts create mode 100644 packages/opencode/src/auth/effect.ts delete mode 100644 packages/opencode/src/auth/service.ts delete mode 100644 packages/opencode/src/permission/service.ts delete mode 100644 packages/opencode/src/provider/auth-service.ts delete mode 100644 packages/opencode/src/question/service.ts diff --git a/packages/opencode/AGENTS.md b/packages/opencode/AGENTS.md index f28150622..e2a0c918d 100644 --- a/packages/opencode/AGENTS.md +++ b/packages/opencode/AGENTS.md @@ -9,71 +9,55 @@ - **Output**: creates `migration/_/migration.sql` and `snapshot.json`. - **Tests**: migration tests should read the per-folder layout (no `_journal.json`). -# opencode Effect guide +# opencode Effect rules -Instructions to follow when writing Effect. +Use these rules when writing or migrating Effect code. -## Schemas +See `specs/effect-migration.md` for the compact pattern reference and examples. -- Use `Schema.Class` for data types with multiple fields. -- Use branded schemas (`Schema.brand`) for single-value types. - -## Services - -- Services use `ServiceMap.Service()("@console/")`. -- In `Layer.effect`, always return service implementations with `ServiceName.of({ ... })`, never a plain object. - -## Errors - -- Use `Schema.TaggedErrorClass` for typed errors. -- For defect-like causes, use `Schema.Defect` instead of `unknown`. -- In `Effect.gen`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches. - -## Effects +## Core - Use `Effect.gen(function* () { ... })` for composition. -- Use `Effect.fn("ServiceName.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers. -- `Effect.fn` / `Effect.fnUntraced` accept pipeable operators as extra arguments, so avoid unnecessary `flow` or outer `.pipe()` wrappers. -- **`Effect.callback`** (not `Effect.async`) for callback-based APIs. The classic `Effect.async` was renamed to `Effect.callback` in effect-smol/v4. - -## Time - +- Use `Effect.fn("Domain.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers. +- `Effect.fn` / `Effect.fnUntraced` accept pipeable operators as extra arguments, so avoid unnecessary outer `.pipe()` wrappers. +- Use `Effect.callback` for callback-based APIs. - Prefer `DateTime.nowAsDate` over `new Date(yield* Clock.currentTimeMillis)` when you need a `Date`. -## Errors +## Schemas and errors + +- Use `Schema.Class` for multi-field data. +- Use branded schemas (`Schema.brand`) for single-value types. +- Use `Schema.TaggedErrorClass` for typed errors. +- Use `Schema.Defect` instead of `unknown` for defect-like causes. +- In `Effect.gen` / `Effect.fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches. -- In `Effect.gen/fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches. +## Runtime vs Instances -## Instance-scoped Effect services +- Use the shared runtime for process-wide services with one lifecycle for the whole app. +- Use `src/effect/instances.ts` for per-directory or per-project services that need `InstanceContext`, per-instance state, or per-instance cleanup. +- If two open directories should not share one copy of the service, it belongs in `Instances`. +- Instance-scoped services should read context from `InstanceContext`, not `Instance.*` globals. -Services that need per-directory lifecycle (created/destroyed per instance) go through the `Instances` LayerMap: +## Preferred Effect services -1. Define a `ServiceMap.Service` with a `static readonly layer` (see `FileWatcherService`, `QuestionService`, `PermissionService`, `ProviderAuthService`). -2. Add it to `InstanceServices` union and `Layer.mergeAll(...)` in `src/effect/instances.ts`. -3. Use `InstanceContext` inside the layer to read `directory` and `project` instead of `Instance.*` globals. -4. Call from legacy code via `runPromiseInstance(MyService.use((s) => s.method()))`. +- In effectified services, prefer yielding existing Effect services over dropping down to ad hoc platform APIs. +- Prefer `FileSystem.FileSystem` instead of raw `fs/promises` for effectful file I/O. +- Prefer `ChildProcessSpawner.ChildProcessSpawner` with `ChildProcess.make(...)` instead of custom process wrappers. +- Prefer `HttpClient.HttpClient` instead of raw `fetch`. +- Prefer `Path.Path`, `Config`, `Clock`, and `DateTime` when those concerns are already inside Effect code. +- For background loops or scheduled tasks, use `Effect.repeat` or `Effect.schedule` with `Effect.forkScoped` in the layer definition. -### Instance.bind — ALS context for native callbacks +## Instance.bind — ALS for native callbacks -`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and returns a wrapper that restores it synchronously when called. +`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and restores it synchronously when called. -**Use it** when passing callbacks to native C/C++ addons (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish`, `Instance.state()`, or anything that reads `Instance.directory`. +Use it for native addon callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish`, `Instance.state()`, or anything that reads `Instance.directory`. -**Don't need it** for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers — Node.js ALS propagates through those automatically. +You do not need it for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers. ```typescript -// Native addon callback — needs Instance.bind const cb = Instance.bind((err, evts) => { Bus.publish(MyEvent, { ... }) }) nativeAddon.subscribe(dir, cb) ``` - -## Flag → Effect.Config migration - -Flags in `src/flag/flag.ts` are being migrated from static `truthy(...)` reads to `Config.boolean(...).pipe(Config.withDefault(false))` as their consumers get effectified. - -- Effectful flags return `Config` and are read with `yield*` inside `Effect.gen`. -- The default `ConfigProvider` reads from `process.env`, so env vars keep working. -- Tests can override via `ConfigProvider.layer(ConfigProvider.fromUnknown({ ... }))`. -- Keep all flags in `flag.ts` as the single registry — just change the implementation from `truthy()` to `Config.boolean()` when the consumer moves to Effect. diff --git a/packages/opencode/specs/effect-migration.md b/packages/opencode/specs/effect-migration.md new file mode 100644 index 000000000..4f195917f --- /dev/null +++ b/packages/opencode/specs/effect-migration.md @@ -0,0 +1,144 @@ +# Effect patterns + +Practical reference for new and migrated Effect code in `packages/opencode`. + +## Choose scope + +Use the shared runtime for process-wide services with one lifecycle for the whole app. + +Use `src/effect/instances.ts` for services that are created per directory or need `InstanceContext`, per-project state, or per-instance cleanup. + +- Shared runtime: config readers, stateless helpers, global clients +- Instance-scoped: watchers, per-project caches, session state, project-bound background work + +Rule of thumb: if two open directories should not share one copy of the service, it belongs in `Instances`. + +## Service shape + +For a fully migrated module, use the public namespace directly: + +```ts +export namespace Foo { + export interface Interface { + readonly get: (id: FooID) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/Foo") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + return Service.of({ + get: Effect.fn("Foo.get")(function* (id) { + return yield* ... + }), + }) + }), + ) + + export const defaultLayer = layer.pipe(Layer.provide(FooRepo.defaultLayer)) +} +``` + +Rules: + +- Keep `Interface`, `Service`, `layer`, and `defaultLayer` on the owning namespace +- Export `defaultLayer` only when wiring dependencies is useful +- Use the direct namespace form once the module is fully migrated + +## Temporary mixed-mode pattern + +Prefer a single namespace whenever possible. + +Use a `*Effect` namespace only when there is a real mixed-mode split, usually because a legacy boundary facade still exists or because merging everything immediately would create awkward cycles. + +```ts +export namespace FooEffect { + export interface Interface { + readonly get: (id: FooID) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/Foo") {} + + export const layer = Layer.effect(...) +} +``` + +Then keep the old boundary thin: + +```ts +export namespace Foo { + export function get(id: FooID) { + return runtime.runPromise(FooEffect.Service.use((svc) => svc.get(id))) + } +} +``` + +Remove the `Effect` suffix when the boundary split is gone. + +## Scheduled Tasks + +For loops or periodic work, use `Effect.repeat` or `Effect.schedule` with `Effect.forkScoped` in the layer definition. + +## Preferred Effect services + +In effectified services, prefer yielding existing Effect services over dropping down to ad hoc platform APIs. + +Prefer these first: + +- `FileSystem.FileSystem` instead of raw `fs/promises` for effectful file I/O +- `ChildProcessSpawner.ChildProcessSpawner` with `ChildProcess.make(...)` instead of custom process wrappers +- `HttpClient.HttpClient` instead of raw `fetch` +- `Path.Path` instead of mixing path helpers into service code when you already need a path service +- `Config` for effect-native configuration reads +- `Clock` / `DateTime` for time reads inside effects + +## Child processes + +For child process work in services, yield `ChildProcessSpawner.ChildProcessSpawner` in the layer and use `ChildProcess.make(...)`. + +Keep shelling-out code inside the service, not in callers. + +## Shared leaf models + +Shared schema or model files can stay outside the service namespace when lower layers also depend on them. + +That is fine for leaf files like `schema.ts`. Keep the service surface in the owning namespace. + +## Migration checklist + +Done now: + +- [x] `AccountEffect` (mixed-mode) +- [x] `AuthEffect` (mixed-mode) +- [x] `TruncateEffect` (mixed-mode) +- [x] `Question` +- [x] `PermissionNext` +- [x] `ProviderAuth` +- [x] `FileWatcher` +- [x] `FileTime` +- [x] `Format` +- [x] `Vcs` +- [x] `Skill` +- [x] `Discovery` +- [x] `File` +- [x] `Snapshot` + +Still open and likely worth migrating: + +- [ ] `Plugin` +- [ ] `ToolRegistry` +- [ ] `Pty` +- [ ] `Worktree` +- [ ] `Installation` +- [ ] `Bus` +- [ ] `Command` +- [ ] `Config` +- [ ] `Session` +- [ ] `SessionProcessor` +- [ ] `SessionPrompt` +- [ ] `SessionCompaction` +- [ ] `Provider` +- [ ] `Project` +- [ ] `LSP` +- [ ] `MCP` diff --git a/packages/opencode/src/account/effect.ts b/packages/opencode/src/account/effect.ts new file mode 100644 index 000000000..444676046 --- /dev/null +++ b/packages/opencode/src/account/effect.ts @@ -0,0 +1,354 @@ +import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" + +import { withTransientReadRetry } from "@/util/effect-http-client" +import { AccountRepo, type AccountRow } from "./repo" +import { + type AccountError, + AccessToken, + Account, + AccountID, + DeviceCode, + RefreshToken, + AccountServiceError, + Login, + Org, + OrgID, + PollDenied, + PollError, + PollExpired, + PollPending, + type PollResult, + PollSlow, + PollSuccess, + UserCode, +} from "./schema" + +export * from "./schema" + +export type AccountOrgs = { + account: Account + orgs: readonly Org[] +} + +class RemoteConfig extends Schema.Class("RemoteConfig")({ + config: Schema.Record(Schema.String, Schema.Json), +}) {} + +const DurationFromSeconds = Schema.Number.pipe( + Schema.decodeTo(Schema.Duration, { + decode: SchemaGetter.transform((n) => Duration.seconds(n)), + encode: SchemaGetter.transform((d) => Duration.toSeconds(d)), + }), +) + +class TokenRefresh extends Schema.Class("TokenRefresh")({ + access_token: AccessToken, + refresh_token: RefreshToken, + expires_in: DurationFromSeconds, +}) {} + +class DeviceAuth extends Schema.Class("DeviceAuth")({ + device_code: DeviceCode, + user_code: UserCode, + verification_uri_complete: Schema.String, + expires_in: DurationFromSeconds, + interval: DurationFromSeconds, +}) {} + +class DeviceTokenSuccess extends Schema.Class("DeviceTokenSuccess")({ + access_token: AccessToken, + refresh_token: RefreshToken, + token_type: Schema.Literal("Bearer"), + expires_in: DurationFromSeconds, +}) {} + +class DeviceTokenError extends Schema.Class("DeviceTokenError")({ + error: Schema.String, + error_description: Schema.String, +}) { + toPollResult(): PollResult { + if (this.error === "authorization_pending") return new PollPending() + if (this.error === "slow_down") return new PollSlow() + if (this.error === "expired_token") return new PollExpired() + if (this.error === "access_denied") return new PollDenied() + return new PollError({ cause: this.error }) + } +} + +const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError]) + +class User extends Schema.Class("User")({ + id: AccountID, + email: Schema.String, +}) {} + +class ClientId extends Schema.Class("ClientId")({ client_id: Schema.String }) {} + +class DeviceTokenRequest extends Schema.Class("DeviceTokenRequest")({ + grant_type: Schema.String, + device_code: DeviceCode, + client_id: Schema.String, +}) {} + +class TokenRefreshRequest extends Schema.Class("TokenRefreshRequest")({ + grant_type: Schema.String, + refresh_token: RefreshToken, + client_id: Schema.String, +}) {} + +const clientId = "opencode-cli" + +const mapAccountServiceError = + (message = "Account service operation failed") => + (effect: Effect.Effect): Effect.Effect => + effect.pipe( + Effect.mapError((cause) => + cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }), + ), + ) + +export namespace AccountEffect { + export interface Interface { + readonly active: () => Effect.Effect, AccountError> + readonly list: () => Effect.Effect + readonly orgsByAccount: () => Effect.Effect + readonly remove: (accountID: AccountID) => Effect.Effect + readonly use: (accountID: AccountID, orgID: Option.Option) => Effect.Effect + readonly orgs: (accountID: AccountID) => Effect.Effect + readonly config: ( + accountID: AccountID, + orgID: OrgID, + ) => Effect.Effect>, AccountError> + readonly token: (accountID: AccountID) => Effect.Effect, AccountError> + readonly login: (url: string) => Effect.Effect + readonly poll: (input: Login) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/Account") {} + + export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const repo = yield* AccountRepo + const http = yield* HttpClient.HttpClient + const httpRead = withTransientReadRetry(http) + const httpOk = HttpClient.filterStatusOk(http) + const httpReadOk = HttpClient.filterStatusOk(httpRead) + + const executeRead = (request: HttpClientRequest.HttpClientRequest) => + httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed")) + + const executeReadOk = (request: HttpClientRequest.HttpClientRequest) => + httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed")) + + const executeEffectOk = (request: Effect.Effect) => + request.pipe( + Effect.flatMap((req) => httpOk.execute(req)), + mapAccountServiceError("HTTP request failed"), + ) + + const resolveToken = Effect.fnUntraced(function* (row: AccountRow) { + const now = yield* Clock.currentTimeMillis + if (row.token_expiry && row.token_expiry > now) return row.access_token + + const response = yield* executeEffectOk( + HttpClientRequest.post(`${row.url}/auth/device/token`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.schemaBodyJson(TokenRefreshRequest)( + new TokenRefreshRequest({ + grant_type: "refresh_token", + refresh_token: row.refresh_token, + client_id: clientId, + }), + ), + ), + ) + + const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe( + mapAccountServiceError("Failed to decode response"), + ) + + const expiry = Option.some(now + Duration.toMillis(parsed.expires_in)) + + yield* repo.persistToken({ + accountID: row.id, + accessToken: parsed.access_token, + refreshToken: parsed.refresh_token, + expiry, + }) + + return parsed.access_token + }) + + const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) { + const maybeAccount = yield* repo.getRow(accountID) + if (Option.isNone(maybeAccount)) return Option.none() + + const account = maybeAccount.value + const accessToken = yield* resolveToken(account) + return Option.some({ account, accessToken }) + }) + + const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) { + const response = yield* executeReadOk( + HttpClientRequest.get(`${url}/api/orgs`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bearerToken(accessToken), + ), + ) + + return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe( + mapAccountServiceError("Failed to decode response"), + ) + }) + + const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) { + const response = yield* executeReadOk( + HttpClientRequest.get(`${url}/api/user`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bearerToken(accessToken), + ), + ) + + return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe( + mapAccountServiceError("Failed to decode response"), + ) + }) + + const token = Effect.fn("Account.token")((accountID: AccountID) => + resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))), + ) + + const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () { + const accounts = yield* repo.list() + const [errors, results] = yield* Effect.partition( + accounts, + (account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))), + { concurrency: 3 }, + ) + for (const error of errors) { + yield* Effect.logWarning("failed to fetch orgs for account").pipe( + Effect.annotateLogs({ error: String(error) }), + ) + } + return results + }) + + const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) { + const resolved = yield* resolveAccess(accountID) + if (Option.isNone(resolved)) return [] + + const { account, accessToken } = resolved.value + + return yield* fetchOrgs(account.url, accessToken) + }) + + const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) { + const resolved = yield* resolveAccess(accountID) + if (Option.isNone(resolved)) return Option.none() + + const { account, accessToken } = resolved.value + + const response = yield* executeRead( + HttpClientRequest.get(`${account.url}/api/config`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bearerToken(accessToken), + HttpClientRequest.setHeaders({ "x-org-id": orgID }), + ), + ) + + if (response.status === 404) return Option.none() + + const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError()) + + const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe( + mapAccountServiceError("Failed to decode response"), + ) + return Option.some(parsed.config) + }) + + const login = Effect.fn("Account.login")(function* (server: string) { + const response = yield* executeEffectOk( + HttpClientRequest.post(`${server}/auth/device/code`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })), + ), + ) + + const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe( + mapAccountServiceError("Failed to decode response"), + ) + return new Login({ + code: parsed.device_code, + user: parsed.user_code, + url: `${server}${parsed.verification_uri_complete}`, + server, + expiry: parsed.expires_in, + interval: parsed.interval, + }) + }) + + const poll = Effect.fn("Account.poll")(function* (input: Login) { + const response = yield* executeEffectOk( + HttpClientRequest.post(`${input.server}/auth/device/token`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.schemaBodyJson(DeviceTokenRequest)( + new DeviceTokenRequest({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: input.code, + client_id: clientId, + }), + ), + ), + ) + + const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe( + mapAccountServiceError("Failed to decode response"), + ) + + if (parsed instanceof DeviceTokenError) return parsed.toPollResult() + const accessToken = parsed.access_token + + const user = fetchUser(input.server, accessToken) + const orgs = fetchOrgs(input.server, accessToken) + + const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 }) + + // TODO: When there are multiple orgs, let the user choose + const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none() + + const now = yield* Clock.currentTimeMillis + const expiry = now + Duration.toMillis(parsed.expires_in) + const refreshToken = parsed.refresh_token + + yield* repo.persistAccount({ + id: account.id, + email: account.email, + url: input.server, + accessToken, + refreshToken, + expiry, + orgID: firstOrgID, + }) + + return new PollSuccess({ email: account.email }) + }) + + return Service.of({ + active: repo.active, + list: repo.list, + orgsByAccount, + remove: repo.remove, + use: repo.use, + orgs, + config, + token, + login, + poll, + }) + }), + ) + + export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer)) +} diff --git a/packages/opencode/src/account/index.ts b/packages/opencode/src/account/index.ts index ed4c3d879..3a9d758e2 100644 --- a/packages/opencode/src/account/index.ts +++ b/packages/opencode/src/account/index.ts @@ -5,20 +5,20 @@ import { type AccountError, type AccessToken, AccountID, - AccountService, + AccountEffect, OrgID, -} from "./service" +} from "./effect" -export { AccessToken, AccountID, OrgID } from "./service" +export { AccessToken, AccountID, OrgID } from "./effect" import { runtime } from "@/effect/runtime" -function runSync(f: (service: AccountService.Service) => Effect.Effect) { - return runtime.runSync(AccountService.use(f)) +function runSync(f: (service: AccountEffect.Interface) => Effect.Effect) { + return runtime.runSync(AccountEffect.Service.use(f)) } -function runPromise(f: (service: AccountService.Service) => Effect.Effect) { - return runtime.runPromise(AccountService.use(f)) +function runPromise(f: (service: AccountEffect.Interface) => Effect.Effect) { + return runtime.runPromise(AccountEffect.Service.use(f)) } export namespace Account { diff --git a/packages/opencode/src/account/service.ts b/packages/opencode/src/account/service.ts deleted file mode 100644 index 87e95c8f4..000000000 --- a/packages/opencode/src/account/service.ts +++ /dev/null @@ -1,359 +0,0 @@ -import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect" -import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" - -import { withTransientReadRetry } from "@/util/effect-http-client" -import { AccountRepo, type AccountRow } from "./repo" -import { - type AccountError, - AccessToken, - Account, - AccountID, - DeviceCode, - RefreshToken, - AccountServiceError, - Login, - Org, - OrgID, - PollDenied, - PollError, - PollExpired, - PollPending, - type PollResult, - PollSlow, - PollSuccess, - UserCode, -} from "./schema" - -export * from "./schema" - -export type AccountOrgs = { - account: Account - orgs: readonly Org[] -} - -class RemoteConfig extends Schema.Class("RemoteConfig")({ - config: Schema.Record(Schema.String, Schema.Json), -}) {} - -const DurationFromSeconds = Schema.Number.pipe( - Schema.decodeTo(Schema.Duration, { - decode: SchemaGetter.transform((n) => Duration.seconds(n)), - encode: SchemaGetter.transform((d) => Duration.toSeconds(d)), - }), -) - -class TokenRefresh extends Schema.Class("TokenRefresh")({ - access_token: AccessToken, - refresh_token: RefreshToken, - expires_in: DurationFromSeconds, -}) {} - -class DeviceAuth extends Schema.Class("DeviceAuth")({ - device_code: DeviceCode, - user_code: UserCode, - verification_uri_complete: Schema.String, - expires_in: DurationFromSeconds, - interval: DurationFromSeconds, -}) {} - -class DeviceTokenSuccess extends Schema.Class("DeviceTokenSuccess")({ - access_token: AccessToken, - refresh_token: RefreshToken, - token_type: Schema.Literal("Bearer"), - expires_in: DurationFromSeconds, -}) {} - -class DeviceTokenError extends Schema.Class("DeviceTokenError")({ - error: Schema.String, - error_description: Schema.String, -}) { - toPollResult(): PollResult { - if (this.error === "authorization_pending") return new PollPending() - if (this.error === "slow_down") return new PollSlow() - if (this.error === "expired_token") return new PollExpired() - if (this.error === "access_denied") return new PollDenied() - return new PollError({ cause: this.error }) - } -} - -const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError]) - -class User extends Schema.Class("User")({ - id: AccountID, - email: Schema.String, -}) {} - -class ClientId extends Schema.Class("ClientId")({ client_id: Schema.String }) {} - -class DeviceTokenRequest extends Schema.Class("DeviceTokenRequest")({ - grant_type: Schema.String, - device_code: DeviceCode, - client_id: Schema.String, -}) {} - -class TokenRefreshRequest extends Schema.Class("TokenRefreshRequest")({ - grant_type: Schema.String, - refresh_token: RefreshToken, - client_id: Schema.String, -}) {} - -const clientId = "opencode-cli" - -const mapAccountServiceError = - (message = "Account service operation failed") => - (effect: Effect.Effect): Effect.Effect => - effect.pipe( - Effect.mapError((cause) => - cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }), - ), - ) - -export namespace AccountService { - export interface Service { - readonly active: () => Effect.Effect, AccountError> - readonly list: () => Effect.Effect - readonly orgsByAccount: () => Effect.Effect - readonly remove: (accountID: AccountID) => Effect.Effect - readonly use: (accountID: AccountID, orgID: Option.Option) => Effect.Effect - readonly orgs: (accountID: AccountID) => Effect.Effect - readonly config: ( - accountID: AccountID, - orgID: OrgID, - ) => Effect.Effect>, AccountError> - readonly token: (accountID: AccountID) => Effect.Effect, AccountError> - readonly login: (url: string) => Effect.Effect - readonly poll: (input: Login) => Effect.Effect - } -} - -export class AccountService extends ServiceMap.Service()("@opencode/Account") { - static readonly layer: Layer.Layer = Layer.effect( - AccountService, - Effect.gen(function* () { - const repo = yield* AccountRepo - const http = yield* HttpClient.HttpClient - const httpRead = withTransientReadRetry(http) - const httpOk = HttpClient.filterStatusOk(http) - const httpReadOk = HttpClient.filterStatusOk(httpRead) - - const executeRead = (request: HttpClientRequest.HttpClientRequest) => - httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed")) - - const executeReadOk = (request: HttpClientRequest.HttpClientRequest) => - httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed")) - - const executeEffectOk = (request: Effect.Effect) => - request.pipe( - Effect.flatMap((req) => httpOk.execute(req)), - mapAccountServiceError("HTTP request failed"), - ) - - // Returns a usable access token for a stored account row, refreshing and - // persisting it when the cached token has expired. - const resolveToken = Effect.fnUntraced(function* (row: AccountRow) { - const now = yield* Clock.currentTimeMillis - if (row.token_expiry && row.token_expiry > now) return row.access_token - - const response = yield* executeEffectOk( - HttpClientRequest.post(`${row.url}/auth/device/token`).pipe( - HttpClientRequest.acceptJson, - HttpClientRequest.schemaBodyJson(TokenRefreshRequest)( - new TokenRefreshRequest({ - grant_type: "refresh_token", - refresh_token: row.refresh_token, - client_id: clientId, - }), - ), - ), - ) - - const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe( - mapAccountServiceError("Failed to decode response"), - ) - - const expiry = Option.some(now + Duration.toMillis(parsed.expires_in)) - - yield* repo.persistToken({ - accountID: row.id, - accessToken: parsed.access_token, - refreshToken: parsed.refresh_token, - expiry, - }) - - return parsed.access_token - }) - - const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) { - const maybeAccount = yield* repo.getRow(accountID) - if (Option.isNone(maybeAccount)) return Option.none() - - const account = maybeAccount.value - const accessToken = yield* resolveToken(account) - return Option.some({ account, accessToken }) - }) - - const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) { - const response = yield* executeReadOk( - HttpClientRequest.get(`${url}/api/orgs`).pipe( - HttpClientRequest.acceptJson, - HttpClientRequest.bearerToken(accessToken), - ), - ) - - return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe( - mapAccountServiceError("Failed to decode response"), - ) - }) - - const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) { - const response = yield* executeReadOk( - HttpClientRequest.get(`${url}/api/user`).pipe( - HttpClientRequest.acceptJson, - HttpClientRequest.bearerToken(accessToken), - ), - ) - - return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe( - mapAccountServiceError("Failed to decode response"), - ) - }) - - const token = Effect.fn("AccountService.token")((accountID: AccountID) => - resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))), - ) - - const orgsByAccount = Effect.fn("AccountService.orgsByAccount")(function* () { - const accounts = yield* repo.list() - const [errors, results] = yield* Effect.partition( - accounts, - (account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))), - { concurrency: 3 }, - ) - for (const error of errors) { - yield* Effect.logWarning("failed to fetch orgs for account").pipe( - Effect.annotateLogs({ error: String(error) }), - ) - } - return results - }) - - const orgs = Effect.fn("AccountService.orgs")(function* (accountID: AccountID) { - const resolved = yield* resolveAccess(accountID) - if (Option.isNone(resolved)) return [] - - const { account, accessToken } = resolved.value - - return yield* fetchOrgs(account.url, accessToken) - }) - - const config = Effect.fn("AccountService.config")(function* (accountID: AccountID, orgID: OrgID) { - const resolved = yield* resolveAccess(accountID) - if (Option.isNone(resolved)) return Option.none() - - const { account, accessToken } = resolved.value - - const response = yield* executeRead( - HttpClientRequest.get(`${account.url}/api/config`).pipe( - HttpClientRequest.acceptJson, - HttpClientRequest.bearerToken(accessToken), - HttpClientRequest.setHeaders({ "x-org-id": orgID }), - ), - ) - - if (response.status === 404) return Option.none() - - const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError()) - - const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe( - mapAccountServiceError("Failed to decode response"), - ) - return Option.some(parsed.config) - }) - - const login = Effect.fn("AccountService.login")(function* (server: string) { - const response = yield* executeEffectOk( - HttpClientRequest.post(`${server}/auth/device/code`).pipe( - HttpClientRequest.acceptJson, - HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })), - ), - ) - - const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe( - mapAccountServiceError("Failed to decode response"), - ) - return new Login({ - code: parsed.device_code, - user: parsed.user_code, - url: `${server}${parsed.verification_uri_complete}`, - server, - expiry: parsed.expires_in, - interval: parsed.interval, - }) - }) - - const poll = Effect.fn("AccountService.poll")(function* (input: Login) { - const response = yield* executeEffectOk( - HttpClientRequest.post(`${input.server}/auth/device/token`).pipe( - HttpClientRequest.acceptJson, - HttpClientRequest.schemaBodyJson(DeviceTokenRequest)( - new DeviceTokenRequest({ - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - device_code: input.code, - client_id: clientId, - }), - ), - ), - ) - - const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe( - mapAccountServiceError("Failed to decode response"), - ) - - if (parsed instanceof DeviceTokenError) return parsed.toPollResult() - const accessToken = parsed.access_token - - const user = fetchUser(input.server, accessToken) - const orgs = fetchOrgs(input.server, accessToken) - - const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 }) - - // TODO: When there are multiple orgs, let the user choose - const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none() - - const now = yield* Clock.currentTimeMillis - const expiry = now + Duration.toMillis(parsed.expires_in) - const refreshToken = parsed.refresh_token - - yield* repo.persistAccount({ - id: account.id, - email: account.email, - url: input.server, - accessToken, - refreshToken, - expiry, - orgID: firstOrgID, - }) - - return new PollSuccess({ email: account.email }) - }) - - return AccountService.of({ - active: repo.active, - list: repo.list, - orgsByAccount, - remove: repo.remove, - use: repo.use, - orgs, - config, - token, - login, - poll, - }) - }), - ) - - static readonly defaultLayer = AccountService.layer.pipe( - Layer.provide(AccountRepo.layer), - Layer.provide(FetchHttpClient.layer), - ) -} diff --git a/packages/opencode/src/auth/effect.ts b/packages/opencode/src/auth/effect.ts new file mode 100644 index 000000000..043b9002e --- /dev/null +++ b/packages/opencode/src/auth/effect.ts @@ -0,0 +1,95 @@ +import path from "path" +import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect" +import { Global } from "../global" +import { Filesystem } from "../util/filesystem" + +export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" + +export class Oauth extends Schema.Class("OAuth")({ + type: Schema.Literal("oauth"), + refresh: Schema.String, + access: Schema.String, + expires: Schema.Number, + accountId: Schema.optional(Schema.String), + enterpriseUrl: Schema.optional(Schema.String), +}) {} + +export class Api extends Schema.Class("ApiAuth")({ + type: Schema.Literal("api"), + key: Schema.String, +}) {} + +export class WellKnown extends Schema.Class("WellKnownAuth")({ + type: Schema.Literal("wellknown"), + key: Schema.String, + token: Schema.String, +}) {} + +export const Info = Schema.Union([Oauth, Api, WellKnown]) +export type Info = Schema.Schema.Type + +export class AuthError extends Schema.TaggedErrorClass()("AuthError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} + +const file = path.join(Global.Path.data, "auth.json") + +const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause }) + +export namespace AuthEffect { + export interface Interface { + readonly get: (providerID: string) => Effect.Effect + readonly all: () => Effect.Effect, AuthError> + readonly set: (key: string, info: Info) => Effect.Effect + readonly remove: (key: string) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/Auth") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const decode = Schema.decodeUnknownOption(Info) + + const all = Effect.fn("Auth.all")(() => + Effect.tryPromise({ + try: async () => { + const data = await Filesystem.readJson>(file).catch(() => ({})) + return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined)) + }, + catch: fail("Failed to read auth data"), + }), + ) + + const get = Effect.fn("Auth.get")(function* (providerID: string) { + return (yield* all())[providerID] + }) + + const set = Effect.fn("Auth.set")(function* (key: string, info: Info) { + const norm = key.replace(/\/+$/, "") + const data = yield* all() + if (norm !== key) delete data[key] + delete data[norm + "/"] + yield* Effect.tryPromise({ + try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600), + catch: fail("Failed to write auth data"), + }) + }) + + const remove = Effect.fn("Auth.remove")(function* (key: string) { + const norm = key.replace(/\/+$/, "") + const data = yield* all() + delete data[key] + delete data[norm] + yield* Effect.tryPromise({ + try: () => Filesystem.writeJson(file, data, 0o600), + catch: fail("Failed to write auth data"), + }) + }) + + return Service.of({ get, all, set, remove }) + }), + ) + +} diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 79e9e615d..6f588e937 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -1,12 +1,12 @@ import { Effect } from "effect" import z from "zod" import { runtime } from "@/effect/runtime" -import * as S from "./service" +import * as S from "./effect" -export { OAUTH_DUMMY_KEY } from "./service" +export { OAUTH_DUMMY_KEY } from "./effect" -function runPromise(f: (service: S.AuthService.Service) => Effect.Effect) { - return runtime.runPromise(S.AuthService.use(f)) +function runPromise(f: (service: S.AuthEffect.Interface) => Effect.Effect) { + return runtime.runPromise(S.AuthEffect.Service.use(f)) } export namespace Auth { diff --git a/packages/opencode/src/auth/service.ts b/packages/opencode/src/auth/service.ts deleted file mode 100644 index 100a132b8..000000000 --- a/packages/opencode/src/auth/service.ts +++ /dev/null @@ -1,101 +0,0 @@ -import path from "path" -import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect" -import { Global } from "../global" -import { Filesystem } from "../util/filesystem" - -export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" - -export class Oauth extends Schema.Class("OAuth")({ - type: Schema.Literal("oauth"), - refresh: Schema.String, - access: Schema.String, - expires: Schema.Number, - accountId: Schema.optional(Schema.String), - enterpriseUrl: Schema.optional(Schema.String), -}) {} - -export class Api extends Schema.Class("ApiAuth")({ - type: Schema.Literal("api"), - key: Schema.String, -}) {} - -export class WellKnown extends Schema.Class("WellKnownAuth")({ - type: Schema.Literal("wellknown"), - key: Schema.String, - token: Schema.String, -}) {} - -export const Info = Schema.Union([Oauth, Api, WellKnown]) -export type Info = Schema.Schema.Type - -export class AuthServiceError extends Schema.TaggedErrorClass()("AuthServiceError", { - message: Schema.String, - cause: Schema.optional(Schema.Defect), -}) {} - -const file = path.join(Global.Path.data, "auth.json") - -const fail = (message: string) => (cause: unknown) => new AuthServiceError({ message, cause }) - -export namespace AuthService { - export interface Service { - readonly get: (providerID: string) => Effect.Effect - readonly all: () => Effect.Effect, AuthServiceError> - readonly set: (key: string, info: Info) => Effect.Effect - readonly remove: (key: string) => Effect.Effect - } -} - -export class AuthService extends ServiceMap.Service()("@opencode/Auth") { - static readonly layer = Layer.effect( - AuthService, - Effect.gen(function* () { - const decode = Schema.decodeUnknownOption(Info) - - const all = Effect.fn("AuthService.all")(() => - Effect.tryPromise({ - try: async () => { - const data = await Filesystem.readJson>(file).catch(() => ({})) - return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined)) - }, - catch: fail("Failed to read auth data"), - }), - ) - - const get = Effect.fn("AuthService.get")(function* (providerID: string) { - return (yield* all())[providerID] - }) - - const set = Effect.fn("AuthService.set")(function* (key: string, info: Info) { - const norm = key.replace(/\/+$/, "") - const data = yield* all() - if (norm !== key) delete data[key] - delete data[norm + "/"] - yield* Effect.tryPromise({ - try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600), - catch: fail("Failed to write auth data"), - }) - }) - - const remove = Effect.fn("AuthService.remove")(function* (key: string) { - const norm = key.replace(/\/+$/, "") - const data = yield* all() - delete data[key] - delete data[norm] - yield* Effect.tryPromise({ - try: () => Filesystem.writeJson(file, data, 0o600), - catch: fail("Failed to write auth data"), - }) - }) - - return AuthService.of({ - get, - all, - set, - remove, - }) - }), - ) - - static readonly defaultLayer = AuthService.layer -} diff --git a/packages/opencode/src/cli/cmd/account.ts b/packages/opencode/src/cli/cmd/account.ts index b2256837d..c2b47da11 100644 --- a/packages/opencode/src/cli/cmd/account.ts +++ b/packages/opencode/src/cli/cmd/account.ts @@ -2,7 +2,7 @@ import { cmd } from "./cmd" import { Duration, Effect, Match, Option } from "effect" import { UI } from "../ui" import { runtime } from "@/effect/runtime" -import { AccountID, AccountService, OrgID, PollExpired, type PollResult } from "@/account/service" +import { AccountID, AccountEffect, OrgID, PollExpired, type PollResult } from "@/account/effect" import { type AccountError } from "@/account/schema" import * as Prompt from "../effect/prompt" import open from "open" @@ -17,7 +17,7 @@ const isActiveOrgChoice = ( ) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID const loginEffect = Effect.fn("login")(function* (url: string) { - const service = yield* AccountService + const service = yield* AccountEffect.Service yield* Prompt.intro("Log in") const login = yield* service.login(url) @@ -58,7 +58,7 @@ const loginEffect = Effect.fn("login")(function* (url: string) { }) const logoutEffect = Effect.fn("logout")(function* (email?: string) { - const service = yield* AccountService + const service = yield* AccountEffect.Service const accounts = yield* service.list() if (accounts.length === 0) return yield* println("Not logged in") @@ -98,7 +98,7 @@ interface OrgChoice { } const switchEffect = Effect.fn("switch")(function* () { - const service = yield* AccountService + const service = yield* AccountEffect.Service const groups = yield* service.orgsByAccount() if (groups.length === 0) return yield* println("Not logged in") @@ -129,7 +129,7 @@ const switchEffect = Effect.fn("switch")(function* () { }) const orgsEffect = Effect.fn("orgs")(function* () { - const service = yield* AccountService + const service = yield* AccountEffect.Service const groups = yield* service.orgsByAccount() if (groups.length === 0) return yield* println("No accounts found") diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts index 3a1fb0cdf..c05458d5d 100644 --- a/packages/opencode/src/effect/instances.ts +++ b/packages/opencode/src/effect/instances.ts @@ -1,31 +1,31 @@ import { Effect, Layer, LayerMap, ServiceMap } from "effect" -import { FileService } from "@/file" -import { FileTimeService } from "@/file/time" -import { FileWatcherService } from "@/file/watcher" -import { FormatService } from "@/format" -import { PermissionEffect } from "@/permission/service" +import { File } from "@/file" +import { FileTime } from "@/file/time" +import { FileWatcher } from "@/file/watcher" +import { Format } from "@/format" +import { PermissionNext } from "@/permission" import { Instance } from "@/project/instance" -import { VcsService } from "@/project/vcs" -import { ProviderAuthService } from "@/provider/auth-service" -import { QuestionService } from "@/question/service" -import { SkillService } from "@/skill/skill" -import { SnapshotService } from "@/snapshot" +import { Vcs } from "@/project/vcs" +import { ProviderAuth } from "@/provider/auth" +import { Question } from "@/question" +import { Skill } from "@/skill/skill" +import { Snapshot } from "@/snapshot" import { InstanceContext } from "./instance-context" import { registerDisposer } from "./instance-registry" export { InstanceContext } from "./instance-context" export type InstanceServices = - | QuestionService - | PermissionEffect.Service - | ProviderAuthService - | FileWatcherService - | VcsService - | FileTimeService - | FormatService - | FileService - | SkillService - | SnapshotService + | Question.Service + | PermissionNext.Service + | ProviderAuth.Service + | FileWatcher.Service + | Vcs.Service + | FileTime.Service + | Format.Service + | File.Service + | Skill.Service + | Snapshot.Service // NOTE: LayerMap only passes the key (directory string) to lookup, but we need // the full instance context (directory, worktree, project). We read from the @@ -36,16 +36,16 @@ export type InstanceServices = function lookup(_key: string) { const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current)) return Layer.mergeAll( - Layer.fresh(QuestionService.layer), - Layer.fresh(PermissionEffect.layer), - Layer.fresh(ProviderAuthService.layer), - Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie), - Layer.fresh(VcsService.layer), - Layer.fresh(FileTimeService.layer).pipe(Layer.orDie), - Layer.fresh(FormatService.layer), - Layer.fresh(FileService.layer), - Layer.fresh(SkillService.layer), - Layer.fresh(SnapshotService.layer), + Layer.fresh(Question.layer), + Layer.fresh(PermissionNext.layer), + Layer.fresh(ProviderAuth.defaultLayer), + Layer.fresh(FileWatcher.layer).pipe(Layer.orDie), + Layer.fresh(Vcs.layer), + Layer.fresh(FileTime.layer).pipe(Layer.orDie), + Layer.fresh(Format.layer), + Layer.fresh(File.layer), + Layer.fresh(Skill.defaultLayer), + Layer.fresh(Snapshot.defaultLayer), ).pipe(Layer.provide(ctx)) } @@ -55,9 +55,7 @@ export class Instances extends ServiceMap.Service Effect.runPromise(layerMap.invalidate(directory))) yield* Effect.addFinalizer(() => Effect.sync(unregister)) return Instances.of(layerMap) diff --git a/packages/opencode/src/effect/runtime.ts b/packages/opencode/src/effect/runtime.ts index a55956bfd..f52203b22 100644 --- a/packages/opencode/src/effect/runtime.ts +++ b/packages/opencode/src/effect/runtime.ts @@ -1,6 +1,6 @@ import { Effect, Layer, ManagedRuntime } from "effect" -import { AccountService } from "@/account/service" -import { AuthService } from "@/auth/service" +import { AccountEffect } from "@/account/effect" +import { AuthEffect } from "@/auth/effect" import { Instances } from "@/effect/instances" import type { InstanceServices } from "@/effect/instances" import { TruncateEffect } from "@/tool/truncate-effect" @@ -8,10 +8,10 @@ import { Instance } from "@/project/instance" export const runtime = ManagedRuntime.make( Layer.mergeAll( - AccountService.defaultLayer, // + AccountEffect.defaultLayer, // TruncateEffect.defaultLayer, Instances.layer, - ).pipe(Layer.provideMerge(AuthService.defaultLayer)), + ).pipe(Layer.provideMerge(AuthEffect.layer)), ) export function runPromiseInstance(effect: Effect.Effect) { diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index cee03e091..6e9b91727 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -1,272 +1,20 @@ import { BusEvent } from "@/bus/bus-event" -import z from "zod" +import { InstanceContext } from "@/effect/instance-context" +import { runPromiseInstance } from "@/effect/runtime" +import { git } from "@/util/git" +import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect" import { formatPatch, structuredPatch } from "diff" -import path from "path" import fs from "fs" -import ignore from "ignore" -import { Log } from "../util/log" -import { Filesystem } from "../util/filesystem" -import { Instance } from "../project/instance" -import { Ripgrep } from "./ripgrep" import fuzzysort from "fuzzysort" +import ignore from "ignore" +import path from "path" +import z from "zod" import { Global } from "../global" -import { git } from "@/util/git" +import { Instance } from "../project/instance" +import { Filesystem } from "../util/filesystem" +import { Log } from "../util/log" import { Protected } from "./protected" -import { InstanceContext } from "@/effect/instance-context" -import { Effect, Layer, ServiceMap } from "effect" -import { runPromiseInstance } from "@/effect/runtime" - -const log = Log.create({ service: "file" }) - -const binaryExtensions = new Set([ - "exe", - "dll", - "pdb", - "bin", - "so", - "dylib", - "o", - "a", - "lib", - "wav", - "mp3", - "ogg", - "oga", - "ogv", - "ogx", - "flac", - "aac", - "wma", - "m4a", - "weba", - "mp4", - "avi", - "mov", - "wmv", - "flv", - "webm", - "mkv", - "zip", - "tar", - "gz", - "gzip", - "bz", - "bz2", - "bzip", - "bzip2", - "7z", - "rar", - "xz", - "lz", - "z", - "pdf", - "doc", - "docx", - "ppt", - "pptx", - "xls", - "xlsx", - "dmg", - "iso", - "img", - "vmdk", - "ttf", - "otf", - "woff", - "woff2", - "eot", - "sqlite", - "db", - "mdb", - "apk", - "ipa", - "aab", - "xapk", - "app", - "pkg", - "deb", - "rpm", - "snap", - "flatpak", - "appimage", - "msi", - "msp", - "jar", - "war", - "ear", - "class", - "kotlin_module", - "dex", - "vdex", - "odex", - "oat", - "art", - "wasm", - "wat", - "bc", - "ll", - "s", - "ko", - "sys", - "drv", - "efi", - "rom", - "com", - "cmd", - "ps1", - "sh", - "bash", - "zsh", - "fish", -]) - -const imageExtensions = new Set([ - "png", - "jpg", - "jpeg", - "gif", - "bmp", - "webp", - "ico", - "tif", - "tiff", - "svg", - "svgz", - "avif", - "apng", - "jxl", - "heic", - "heif", - "raw", - "cr2", - "nef", - "arw", - "dng", - "orf", - "raf", - "pef", - "x3f", -]) - -const textExtensions = new Set([ - "ts", - "tsx", - "mts", - "cts", - "mtsx", - "ctsx", - "js", - "jsx", - "mjs", - "cjs", - "sh", - "bash", - "zsh", - "fish", - "ps1", - "psm1", - "cmd", - "bat", - "json", - "jsonc", - "json5", - "yaml", - "yml", - "toml", - "md", - "mdx", - "txt", - "xml", - "html", - "htm", - "css", - "scss", - "sass", - "less", - "graphql", - "gql", - "sql", - "ini", - "cfg", - "conf", - "env", -]) - -const textNames = new Set([ - "dockerfile", - "makefile", - ".gitignore", - ".gitattributes", - ".editorconfig", - ".npmrc", - ".nvmrc", - ".prettierrc", - ".eslintrc", -]) - -function isImageByExtension(filepath: string): boolean { - const ext = path.extname(filepath).toLowerCase().slice(1) - return imageExtensions.has(ext) -} - -function isTextByExtension(filepath: string): boolean { - const ext = path.extname(filepath).toLowerCase().slice(1) - return textExtensions.has(ext) -} - -function isTextByName(filepath: string): boolean { - const name = path.basename(filepath).toLowerCase() - return textNames.has(name) -} - -function getImageMimeType(filepath: string): string { - const ext = path.extname(filepath).toLowerCase().slice(1) - const mimeTypes: Record = { - png: "image/png", - jpg: "image/jpeg", - jpeg: "image/jpeg", - gif: "image/gif", - bmp: "image/bmp", - webp: "image/webp", - ico: "image/x-icon", - tif: "image/tiff", - tiff: "image/tiff", - svg: "image/svg+xml", - svgz: "image/svg+xml", - avif: "image/avif", - apng: "image/apng", - jxl: "image/jxl", - heic: "image/heic", - heif: "image/heif", - } - return mimeTypes[ext] || "image/" + ext -} - -function isBinaryByExtension(filepath: string): boolean { - const ext = path.extname(filepath).toLowerCase().slice(1) - return binaryExtensions.has(ext) -} - -function isImage(mimeType: string): boolean { - return mimeType.startsWith("image/") -} - -function shouldEncode(mimeType: string): boolean { - const type = mimeType.toLowerCase() - log.info("shouldEncode", { type }) - if (!type) return false - - if (type.startsWith("text/")) return false - if (type.includes("charset=")) return false - - const parts = type.split("/", 2) - const top = parts[0] - - const tops = ["image", "audio", "video", "font", "model", "multipart"] - if (tops.includes(top)) return true - - return false -} +import { Ripgrep } from "./ripgrep" export namespace File { export const Info = z @@ -336,28 +84,270 @@ export namespace File { } export function init() { - return runPromiseInstance(FileService.use((s) => s.init())) + return runPromiseInstance(Service.use((svc) => svc.init())) } export async function status() { - return runPromiseInstance(FileService.use((s) => s.status())) + return runPromiseInstance(Service.use((svc) => svc.status())) } export async function read(file: string): Promise { - return runPromiseInstance(FileService.use((s) => s.read(file))) + return runPromiseInstance(Service.use((svc) => svc.read(file))) } export async function list(dir?: string) { - return runPromiseInstance(FileService.use((s) => s.list(dir))) + return runPromiseInstance(Service.use((svc) => svc.list(dir))) } export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) { - return runPromiseInstance(FileService.use((s) => s.search(input))) + return runPromiseInstance(Service.use((svc) => svc.search(input))) + } + + const log = Log.create({ service: "file" }) + + const binary = new Set([ + "exe", + "dll", + "pdb", + "bin", + "so", + "dylib", + "o", + "a", + "lib", + "wav", + "mp3", + "ogg", + "oga", + "ogv", + "ogx", + "flac", + "aac", + "wma", + "m4a", + "weba", + "mp4", + "avi", + "mov", + "wmv", + "flv", + "webm", + "mkv", + "zip", + "tar", + "gz", + "gzip", + "bz", + "bz2", + "bzip", + "bzip2", + "7z", + "rar", + "xz", + "lz", + "z", + "pdf", + "doc", + "docx", + "ppt", + "pptx", + "xls", + "xlsx", + "dmg", + "iso", + "img", + "vmdk", + "ttf", + "otf", + "woff", + "woff2", + "eot", + "sqlite", + "db", + "mdb", + "apk", + "ipa", + "aab", + "xapk", + "app", + "pkg", + "deb", + "rpm", + "snap", + "flatpak", + "appimage", + "msi", + "msp", + "jar", + "war", + "ear", + "class", + "kotlin_module", + "dex", + "vdex", + "odex", + "oat", + "art", + "wasm", + "wat", + "bc", + "ll", + "s", + "ko", + "sys", + "drv", + "efi", + "rom", + "com", + "cmd", + "ps1", + "sh", + "bash", + "zsh", + "fish", + ]) + + const image = new Set([ + "png", + "jpg", + "jpeg", + "gif", + "bmp", + "webp", + "ico", + "tif", + "tiff", + "svg", + "svgz", + "avif", + "apng", + "jxl", + "heic", + "heif", + "raw", + "cr2", + "nef", + "arw", + "dng", + "orf", + "raf", + "pef", + "x3f", + ]) + + const text = new Set([ + "ts", + "tsx", + "mts", + "cts", + "mtsx", + "ctsx", + "js", + "jsx", + "mjs", + "cjs", + "sh", + "bash", + "zsh", + "fish", + "ps1", + "psm1", + "cmd", + "bat", + "json", + "jsonc", + "json5", + "yaml", + "yml", + "toml", + "md", + "mdx", + "txt", + "xml", + "html", + "htm", + "css", + "scss", + "sass", + "less", + "graphql", + "gql", + "sql", + "ini", + "cfg", + "conf", + "env", + ]) + + const textName = new Set([ + "dockerfile", + "makefile", + ".gitignore", + ".gitattributes", + ".editorconfig", + ".npmrc", + ".nvmrc", + ".prettierrc", + ".eslintrc", + ]) + + const mime: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + bmp: "image/bmp", + webp: "image/webp", + ico: "image/x-icon", + tif: "image/tiff", + tiff: "image/tiff", + svg: "image/svg+xml", + svgz: "image/svg+xml", + avif: "image/avif", + apng: "image/apng", + jxl: "image/jxl", + heic: "image/heic", + heif: "image/heif", + } + + type Entry = { files: string[]; dirs: string[] } + + const ext = (file: string) => path.extname(file).toLowerCase().slice(1) + const name = (file: string) => path.basename(file).toLowerCase() + const isImageByExtension = (file: string) => image.has(ext(file)) + const isTextByExtension = (file: string) => text.has(ext(file)) + const isTextByName = (file: string) => textName.has(name(file)) + const isBinaryByExtension = (file: string) => binary.has(ext(file)) + const isImage = (mimeType: string) => mimeType.startsWith("image/") + const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file) + + function shouldEncode(mimeType: string) { + const type = mimeType.toLowerCase() + log.info("shouldEncode", { type }) + if (!type) return false + if (type.startsWith("text/")) return false + if (type.includes("charset=")) return false + const top = type.split("/", 2)[0] + return ["image", "audio", "video", "font", "model", "multipart"].includes(top) + } + + const hidden = (item: string) => { + const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "") + return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1) + } + + const sortHiddenLast = (items: string[], prefer: boolean) => { + if (prefer) return items + const visible: string[] = [] + const hiddenItems: string[] = [] + for (const item of items) { + if (hidden(item)) hiddenItems.push(item) + else visible.push(item) + } + return [...visible, ...hiddenItems] } -} -export namespace FileService { - export interface Service { + export interface Interface { readonly init: () => Effect.Effect readonly status: () => Effect.Effect readonly read: (file: string) => Effect.Effect @@ -369,89 +359,83 @@ export namespace FileService { type?: "file" | "directory" }) => Effect.Effect } -} -export class FileService extends ServiceMap.Service()("@opencode/File") { - static readonly layer = Layer.effect( - FileService, + export class Service extends ServiceMap.Service()("@opencode/File") {} + + export const layer = Layer.effect( + Service, Effect.gen(function* () { const instance = yield* InstanceContext - - // File cache state - type Entry = { files: string[]; dirs: string[] } let cache: Entry = { files: [], dirs: [] } - let task: Promise | undefined - const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global" - function kick() { - if (task) return task - task = (async () => { - // Disable scanning if in root of file system - if (instance.directory === path.parse(instance.directory).root) return - const next: Entry = { files: [], dirs: [] } - try { - if (isGlobalHome) { - const dirs = new Set() - const protectedNames = Protected.names() - - const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"]) - const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name) - const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name) - - const top = await fs.promises - .readdir(instance.directory, { withFileTypes: true }) - .catch(() => [] as fs.Dirent[]) - - for (const entry of top) { - if (!entry.isDirectory()) continue - if (shouldIgnoreName(entry.name)) continue - dirs.add(entry.name + "/") - - const base = path.join(instance.directory, entry.name) - const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[]) - for (const child of children) { - if (!child.isDirectory()) continue - if (shouldIgnoreNested(child.name)) continue - dirs.add(entry.name + "/" + child.name + "/") - } + const scan = Effect.fn("File.scan")(function* () { + if (instance.directory === path.parse(instance.directory).root) return + const next: Entry = { files: [], dirs: [] } + + yield* Effect.promise(async () => { + if (isGlobalHome) { + const dirs = new Set() + const protectedNames = Protected.names() + const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"]) + const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name) + const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name) + const top = await fs.promises + .readdir(instance.directory, { withFileTypes: true }) + .catch(() => [] as fs.Dirent[]) + + for (const entry of top) { + if (!entry.isDirectory()) continue + if (shouldIgnoreName(entry.name)) continue + dirs.add(entry.name + "/") + + const base = path.join(instance.directory, entry.name) + const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[]) + for (const child of children) { + if (!child.isDirectory()) continue + if (shouldIgnoreNested(child.name)) continue + dirs.add(entry.name + "/" + child.name + "/") } + } - next.dirs = Array.from(dirs).toSorted() - } else { - const set = new Set() - for await (const file of Ripgrep.files({ cwd: instance.directory })) { - next.files.push(file) - let current = file - while (true) { - const dir = path.dirname(current) - if (dir === ".") break - if (dir === current) break - current = dir - if (set.has(dir)) continue - set.add(dir) - next.dirs.push(dir + "/") - } + next.dirs = Array.from(dirs).toSorted() + } else { + const seen = new Set() + for await (const file of Ripgrep.files({ cwd: instance.directory })) { + next.files.push(file) + let current = file + while (true) { + const dir = path.dirname(current) + if (dir === ".") break + if (dir === current) break + current = dir + if (seen.has(dir)) continue + seen.add(dir) + next.dirs.push(dir + "/") } } - cache = next - } finally { - task = undefined } - })() - return task - } + }) - const getFiles = async () => { - void kick() - return cache - } + cache = next + }) - const init = Effect.fn("FileService.init")(function* () { - yield* Effect.promise(() => kick()) + const getFiles = () => cache + + const scope = yield* Scope.Scope + let fiber: Fiber.Fiber | undefined + + const init = Effect.fn("File.init")(function* () { + if (!fiber) { + fiber = yield* scan().pipe( + Effect.catchCause(() => Effect.void), + Effect.forkIn(scope), + ) + } + yield* Fiber.join(fiber) }) - const status = Effect.fn("FileService.status")(function* () { + const status = Effect.fn("File.status")(function* () { if (instance.project.vcs !== "git") return [] return yield* Effect.promise(async () => { @@ -461,14 +445,13 @@ export class FileService extends ServiceMap.Service { - const full = path.isAbsolute(x.path) ? x.path : path.join(instance.directory, x.path) + return changed.map((item) => { + const full = path.isAbsolute(item.path) ? item.path : path.join(instance.directory, item.path) return { - ...x, + ...item, path: path.relative(instance.directory, full), } }) }) }) - const read = Effect.fn("FileService.read")(function* (file: string) { + const read = Effect.fn("File.read")(function* (file: string) { return yield* Effect.promise(async (): Promise => { using _ = log.time("read", { file }) const full = path.join(instance.directory, file) if (!Instance.containsPath(full)) { - throw new Error(`Access denied: path escapes project directory`) + throw new Error("Access denied: path escapes project directory") } - // Fast path: check extension before any filesystem operations if (isImageByExtension(file)) { if (await Filesystem.exists(full)) { const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([])) - const content = buffer.toString("base64") - const mimeType = getImageMimeType(file) - return { type: "text", content, mimeType, encoding: "base64" } + return { + type: "text", + content: buffer.toString("base64"), + mimeType: getImageMimeType(file), + encoding: "base64", + } } return { type: "text", content: "" } } - const text = isTextByExtension(file) || isTextByName(file) + const knownText = isTextByExtension(file) || isTextByName(file) - if (isBinaryByExtension(file) && !text) { + if (isBinaryByExtension(file) && !knownText) { return { type: "binary", content: "" } } @@ -583,7 +564,7 @@ export class FileService extends ServiceMap.Service Buffer.from([])) - const content = buffer.toString("base64") - return { type: "text", content, mimeType, encoding: "base64" } + return { + type: "text", + content: buffer.toString("base64"), + mimeType, + encoding: "base64", + } } const content = (await Filesystem.readText(full).catch(() => "")).trim() @@ -603,7 +588,9 @@ export class FileService extends ServiceMap.Service { const exclude = [".git", ".DS_Store"] let ignored = (_: string) => false if (instance.project.vcs === "git") { const ig = ignore() - const gitignorePath = path.join(instance.project.worktree, ".gitignore") - if (await Filesystem.exists(gitignorePath)) { - ig.add(await Filesystem.readText(gitignorePath)) + const gitignore = path.join(instance.project.worktree, ".gitignore") + if (await Filesystem.exists(gitignore)) { + ig.add(await Filesystem.readText(gitignore)) } - const ignorePath = path.join(instance.project.worktree, ".ignore") - if (await Filesystem.exists(ignorePath)) { - ig.add(await Filesystem.readText(ignorePath)) + const ignoreFile = path.join(instance.project.worktree, ".ignore") + if (await Filesystem.exists(ignoreFile)) { + ig.add(await Filesystem.readText(ignoreFile)) } ignored = ig.ignores.bind(ig) } - const resolved = dir ? path.join(instance.directory, dir) : instance.directory + const resolved = dir ? path.join(instance.directory, dir) : instance.directory if (!Instance.containsPath(resolved)) { - throw new Error(`Access denied: path escapes project directory`) + throw new Error("Access denied: path escapes project directory") } const nodes: File.Node[] = [] - for (const entry of await fs.promises - .readdir(resolved, { - withFileTypes: true, - }) - .catch(() => [])) { + for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) { if (exclude.includes(entry.name)) continue - const fullPath = path.join(resolved, entry.name) - const relativePath = path.relative(instance.directory, fullPath) + const absolute = path.join(resolved, entry.name) + const file = path.relative(instance.directory, absolute) const type = entry.isDirectory() ? "directory" : "file" nodes.push({ name: entry.name, - path: relativePath, - absolute: fullPath, + path: file, + absolute, type, - ignored: ignored(type === "directory" ? relativePath + "/" : relativePath), + ignored: ignored(type === "directory" ? file + "/" : file), }) } + return nodes.sort((a, b) => { - if (a.type !== b.type) { - return a.type === "directory" ? -1 : 1 - } + if (a.type !== b.type) return a.type === "directory" ? -1 : 1 return a.name.localeCompare(b.name) }) }) }) - const search = Effect.fn("FileService.search")(function* (input: { + const search = Effect.fn("File.search")(function* (input: { query: string limit?: number dirs?: boolean @@ -681,35 +668,20 @@ export class FileService extends ServiceMap.Service