mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-25 10:04:40 +00:00
refactor(effect): unify service namespaces and align naming (#18093)
This commit is contained in:
@@ -9,71 +9,55 @@
|
|||||||
- **Output**: creates `migration/<timestamp>_<slug>/migration.sql` and `snapshot.json`.
|
- **Output**: creates `migration/<timestamp>_<slug>/migration.sql` and `snapshot.json`.
|
||||||
- **Tests**: migration tests should read the per-folder layout (no `_journal.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.
|
## Core
|
||||||
- Use branded schemas (`Schema.brand`) for single-value types.
|
|
||||||
|
|
||||||
## Services
|
|
||||||
|
|
||||||
- Services use `ServiceMap.Service<ServiceName, ServiceName.Service>()("@console/<Name>")`.
|
|
||||||
- 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
|
|
||||||
|
|
||||||
- Use `Effect.gen(function* () { ... })` for composition.
|
- Use `Effect.gen(function* () { ... })` for composition.
|
||||||
- Use `Effect.fn("ServiceName.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers.
|
- 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 `flow` or outer `.pipe()` wrappers.
|
- `Effect.fn` / `Effect.fnUntraced` accept pipeable operators as extra arguments, so avoid unnecessary 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.
|
- Use `Effect.callback` for callback-based APIs.
|
||||||
|
|
||||||
## Time
|
|
||||||
|
|
||||||
- Prefer `DateTime.nowAsDate` over `new Date(yield* Clock.currentTimeMillis)` when you need a `Date`.
|
- Prefer `DateTime.nowAsDate` over `new Date(yield* Clock.currentTimeMillis)` when you need a `Date`.
|
||||||
|
|
||||||
## Errors
|
## Schemas and errors
|
||||||
|
|
||||||
- In `Effect.gen/fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches.
|
- 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.
|
||||||
|
|
||||||
## Instance-scoped Effect services
|
## Runtime vs Instances
|
||||||
|
|
||||||
Services that need per-directory lifecycle (created/destroyed per instance) go through the `Instances` LayerMap:
|
- 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.
|
||||||
|
|
||||||
1. Define a `ServiceMap.Service` with a `static readonly layer` (see `FileWatcherService`, `QuestionService`, `PermissionService`, `ProviderAuthService`).
|
## Preferred Effect services
|
||||||
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()))`.
|
|
||||||
|
|
||||||
### Instance.bind — ALS context for native callbacks
|
- 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(fn)` captures the current Instance AsyncLocalStorage context and returns a wrapper that restores it synchronously when called.
|
## Instance.bind — ALS for native callbacks
|
||||||
|
|
||||||
**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`.
|
`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and restores it synchronously when called.
|
||||||
|
|
||||||
**Don't need it** for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers — Node.js ALS propagates through those automatically.
|
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`.
|
||||||
|
|
||||||
|
You do not need it for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Native addon callback — needs Instance.bind
|
|
||||||
const cb = Instance.bind((err, evts) => {
|
const cb = Instance.bind((err, evts) => {
|
||||||
Bus.publish(MyEvent, { ... })
|
Bus.publish(MyEvent, { ... })
|
||||||
})
|
})
|
||||||
nativeAddon.subscribe(dir, cb)
|
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<boolean>` 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.
|
|
||||||
|
|||||||
144
packages/opencode/specs/effect-migration.md
Normal file
144
packages/opencode/specs/effect-migration.md
Normal file
@@ -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<FooInfo, FooError>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, Interface>()("@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<Foo, FooError>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, Interface>()("@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`
|
||||||
@@ -108,8 +108,8 @@ const mapAccountServiceError =
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
export namespace AccountService {
|
export namespace AccountEffect {
|
||||||
export interface Service {
|
export interface Interface {
|
||||||
readonly active: () => Effect.Effect<Option.Option<Account>, AccountError>
|
readonly active: () => Effect.Effect<Option.Option<Account>, AccountError>
|
||||||
readonly list: () => Effect.Effect<Account[], AccountError>
|
readonly list: () => Effect.Effect<Account[], AccountError>
|
||||||
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
|
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
|
||||||
@@ -124,11 +124,11 @@ export namespace AccountService {
|
|||||||
readonly login: (url: string) => Effect.Effect<Login, AccountError>
|
readonly login: (url: string) => Effect.Effect<Login, AccountError>
|
||||||
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
|
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export class AccountService extends ServiceMap.Service<AccountService, AccountService.Service>()("@opencode/Account") {
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Account") {}
|
||||||
static readonly layer: Layer.Layer<AccountService, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
|
|
||||||
AccountService,
|
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
|
||||||
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const repo = yield* AccountRepo
|
const repo = yield* AccountRepo
|
||||||
const http = yield* HttpClient.HttpClient
|
const http = yield* HttpClient.HttpClient
|
||||||
@@ -148,8 +148,6 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
|
|||||||
mapAccountServiceError("HTTP request failed"),
|
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 resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
|
||||||
const now = yield* Clock.currentTimeMillis
|
const now = yield* Clock.currentTimeMillis
|
||||||
if (row.token_expiry && row.token_expiry > now) return row.access_token
|
if (row.token_expiry && row.token_expiry > now) return row.access_token
|
||||||
@@ -218,11 +216,11 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const token = Effect.fn("AccountService.token")((accountID: AccountID) =>
|
const token = Effect.fn("Account.token")((accountID: AccountID) =>
|
||||||
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
|
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
|
||||||
)
|
)
|
||||||
|
|
||||||
const orgsByAccount = Effect.fn("AccountService.orgsByAccount")(function* () {
|
const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
|
||||||
const accounts = yield* repo.list()
|
const accounts = yield* repo.list()
|
||||||
const [errors, results] = yield* Effect.partition(
|
const [errors, results] = yield* Effect.partition(
|
||||||
accounts,
|
accounts,
|
||||||
@@ -237,7 +235,7 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
|
|||||||
return results
|
return results
|
||||||
})
|
})
|
||||||
|
|
||||||
const orgs = Effect.fn("AccountService.orgs")(function* (accountID: AccountID) {
|
const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
|
||||||
const resolved = yield* resolveAccess(accountID)
|
const resolved = yield* resolveAccess(accountID)
|
||||||
if (Option.isNone(resolved)) return []
|
if (Option.isNone(resolved)) return []
|
||||||
|
|
||||||
@@ -246,7 +244,7 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
|
|||||||
return yield* fetchOrgs(account.url, accessToken)
|
return yield* fetchOrgs(account.url, accessToken)
|
||||||
})
|
})
|
||||||
|
|
||||||
const config = Effect.fn("AccountService.config")(function* (accountID: AccountID, orgID: OrgID) {
|
const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
|
||||||
const resolved = yield* resolveAccess(accountID)
|
const resolved = yield* resolveAccess(accountID)
|
||||||
if (Option.isNone(resolved)) return Option.none()
|
if (Option.isNone(resolved)) return Option.none()
|
||||||
|
|
||||||
@@ -270,7 +268,7 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
|
|||||||
return Option.some(parsed.config)
|
return Option.some(parsed.config)
|
||||||
})
|
})
|
||||||
|
|
||||||
const login = Effect.fn("AccountService.login")(function* (server: string) {
|
const login = Effect.fn("Account.login")(function* (server: string) {
|
||||||
const response = yield* executeEffectOk(
|
const response = yield* executeEffectOk(
|
||||||
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
|
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
|
||||||
HttpClientRequest.acceptJson,
|
HttpClientRequest.acceptJson,
|
||||||
@@ -291,7 +289,7 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const poll = Effect.fn("AccountService.poll")(function* (input: Login) {
|
const poll = Effect.fn("Account.poll")(function* (input: Login) {
|
||||||
const response = yield* executeEffectOk(
|
const response = yield* executeEffectOk(
|
||||||
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
|
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
|
||||||
HttpClientRequest.acceptJson,
|
HttpClientRequest.acceptJson,
|
||||||
@@ -337,7 +335,7 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
|
|||||||
return new PollSuccess({ email: account.email })
|
return new PollSuccess({ email: account.email })
|
||||||
})
|
})
|
||||||
|
|
||||||
return AccountService.of({
|
return Service.of({
|
||||||
active: repo.active,
|
active: repo.active,
|
||||||
list: repo.list,
|
list: repo.list,
|
||||||
orgsByAccount,
|
orgsByAccount,
|
||||||
@@ -352,8 +350,5 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
static readonly defaultLayer = AccountService.layer.pipe(
|
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
|
||||||
Layer.provide(AccountRepo.layer),
|
|
||||||
Layer.provide(FetchHttpClient.layer),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
@@ -5,20 +5,20 @@ import {
|
|||||||
type AccountError,
|
type AccountError,
|
||||||
type AccessToken,
|
type AccessToken,
|
||||||
AccountID,
|
AccountID,
|
||||||
AccountService,
|
AccountEffect,
|
||||||
OrgID,
|
OrgID,
|
||||||
} from "./service"
|
} from "./effect"
|
||||||
|
|
||||||
export { AccessToken, AccountID, OrgID } from "./service"
|
export { AccessToken, AccountID, OrgID } from "./effect"
|
||||||
|
|
||||||
import { runtime } from "@/effect/runtime"
|
import { runtime } from "@/effect/runtime"
|
||||||
|
|
||||||
function runSync<A>(f: (service: AccountService.Service) => Effect.Effect<A, AccountError>) {
|
function runSync<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) {
|
||||||
return runtime.runSync(AccountService.use(f))
|
return runtime.runSync(AccountEffect.Service.use(f))
|
||||||
}
|
}
|
||||||
|
|
||||||
function runPromise<A>(f: (service: AccountService.Service) => Effect.Effect<A, AccountError>) {
|
function runPromise<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) {
|
||||||
return runtime.runPromise(AccountService.use(f))
|
return runtime.runPromise(AccountEffect.Service.use(f))
|
||||||
}
|
}
|
||||||
|
|
||||||
export namespace Account {
|
export namespace Account {
|
||||||
|
|||||||
@@ -28,31 +28,31 @@ export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
|
|||||||
export const Info = Schema.Union([Oauth, Api, WellKnown])
|
export const Info = Schema.Union([Oauth, Api, WellKnown])
|
||||||
export type Info = Schema.Schema.Type<typeof Info>
|
export type Info = Schema.Schema.Type<typeof Info>
|
||||||
|
|
||||||
export class AuthServiceError extends Schema.TaggedErrorClass<AuthServiceError>()("AuthServiceError", {
|
export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
cause: Schema.optional(Schema.Defect),
|
cause: Schema.optional(Schema.Defect),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
const file = path.join(Global.Path.data, "auth.json")
|
const file = path.join(Global.Path.data, "auth.json")
|
||||||
|
|
||||||
const fail = (message: string) => (cause: unknown) => new AuthServiceError({ message, cause })
|
const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause })
|
||||||
|
|
||||||
export namespace AuthService {
|
export namespace AuthEffect {
|
||||||
export interface Service {
|
export interface Interface {
|
||||||
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthServiceError>
|
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
|
||||||
readonly all: () => Effect.Effect<Record<string, Info>, AuthServiceError>
|
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
|
||||||
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthServiceError>
|
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
|
||||||
readonly remove: (key: string) => Effect.Effect<void, AuthServiceError>
|
readonly remove: (key: string) => Effect.Effect<void, AuthError>
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export class AuthService extends ServiceMap.Service<AuthService, AuthService.Service>()("@opencode/Auth") {
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Auth") {}
|
||||||
static readonly layer = Layer.effect(
|
|
||||||
AuthService,
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const decode = Schema.decodeUnknownOption(Info)
|
const decode = Schema.decodeUnknownOption(Info)
|
||||||
|
|
||||||
const all = Effect.fn("AuthService.all")(() =>
|
const all = Effect.fn("Auth.all")(() =>
|
||||||
Effect.tryPromise({
|
Effect.tryPromise({
|
||||||
try: async () => {
|
try: async () => {
|
||||||
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
|
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
|
||||||
@@ -62,11 +62,11 @@ export class AuthService extends ServiceMap.Service<AuthService, AuthService.Ser
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const get = Effect.fn("AuthService.get")(function* (providerID: string) {
|
const get = Effect.fn("Auth.get")(function* (providerID: string) {
|
||||||
return (yield* all())[providerID]
|
return (yield* all())[providerID]
|
||||||
})
|
})
|
||||||
|
|
||||||
const set = Effect.fn("AuthService.set")(function* (key: string, info: Info) {
|
const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
|
||||||
const norm = key.replace(/\/+$/, "")
|
const norm = key.replace(/\/+$/, "")
|
||||||
const data = yield* all()
|
const data = yield* all()
|
||||||
if (norm !== key) delete data[key]
|
if (norm !== key) delete data[key]
|
||||||
@@ -77,7 +77,7 @@ export class AuthService extends ServiceMap.Service<AuthService, AuthService.Ser
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const remove = Effect.fn("AuthService.remove")(function* (key: string) {
|
const remove = Effect.fn("Auth.remove")(function* (key: string) {
|
||||||
const norm = key.replace(/\/+$/, "")
|
const norm = key.replace(/\/+$/, "")
|
||||||
const data = yield* all()
|
const data = yield* all()
|
||||||
delete data[key]
|
delete data[key]
|
||||||
@@ -88,14 +88,8 @@ export class AuthService extends ServiceMap.Service<AuthService, AuthService.Ser
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return AuthService.of({
|
return Service.of({ get, all, set, remove })
|
||||||
get,
|
|
||||||
all,
|
|
||||||
set,
|
|
||||||
remove,
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
static readonly defaultLayer = AuthService.layer
|
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { runtime } from "@/effect/runtime"
|
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<A>(f: (service: S.AuthService.Service) => Effect.Effect<A, S.AuthServiceError>) {
|
function runPromise<A>(f: (service: S.AuthEffect.Interface) => Effect.Effect<A, S.AuthError>) {
|
||||||
return runtime.runPromise(S.AuthService.use(f))
|
return runtime.runPromise(S.AuthEffect.Service.use(f))
|
||||||
}
|
}
|
||||||
|
|
||||||
export namespace Auth {
|
export namespace Auth {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { cmd } from "./cmd"
|
|||||||
import { Duration, Effect, Match, Option } from "effect"
|
import { Duration, Effect, Match, Option } from "effect"
|
||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
import { runtime } from "@/effect/runtime"
|
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 { type AccountError } from "@/account/schema"
|
||||||
import * as Prompt from "../effect/prompt"
|
import * as Prompt from "../effect/prompt"
|
||||||
import open from "open"
|
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
|
) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID
|
||||||
|
|
||||||
const loginEffect = Effect.fn("login")(function* (url: string) {
|
const loginEffect = Effect.fn("login")(function* (url: string) {
|
||||||
const service = yield* AccountService
|
const service = yield* AccountEffect.Service
|
||||||
|
|
||||||
yield* Prompt.intro("Log in")
|
yield* Prompt.intro("Log in")
|
||||||
const login = yield* service.login(url)
|
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 logoutEffect = Effect.fn("logout")(function* (email?: string) {
|
||||||
const service = yield* AccountService
|
const service = yield* AccountEffect.Service
|
||||||
const accounts = yield* service.list()
|
const accounts = yield* service.list()
|
||||||
if (accounts.length === 0) return yield* println("Not logged in")
|
if (accounts.length === 0) return yield* println("Not logged in")
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ interface OrgChoice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const switchEffect = Effect.fn("switch")(function* () {
|
const switchEffect = Effect.fn("switch")(function* () {
|
||||||
const service = yield* AccountService
|
const service = yield* AccountEffect.Service
|
||||||
|
|
||||||
const groups = yield* service.orgsByAccount()
|
const groups = yield* service.orgsByAccount()
|
||||||
if (groups.length === 0) return yield* println("Not logged in")
|
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 orgsEffect = Effect.fn("orgs")(function* () {
|
||||||
const service = yield* AccountService
|
const service = yield* AccountEffect.Service
|
||||||
|
|
||||||
const groups = yield* service.orgsByAccount()
|
const groups = yield* service.orgsByAccount()
|
||||||
if (groups.length === 0) return yield* println("No accounts found")
|
if (groups.length === 0) return yield* println("No accounts found")
|
||||||
|
|||||||
@@ -1,31 +1,31 @@
|
|||||||
import { Effect, Layer, LayerMap, ServiceMap } from "effect"
|
import { Effect, Layer, LayerMap, ServiceMap } from "effect"
|
||||||
import { FileService } from "@/file"
|
import { File } from "@/file"
|
||||||
import { FileTimeService } from "@/file/time"
|
import { FileTime } from "@/file/time"
|
||||||
import { FileWatcherService } from "@/file/watcher"
|
import { FileWatcher } from "@/file/watcher"
|
||||||
import { FormatService } from "@/format"
|
import { Format } from "@/format"
|
||||||
import { PermissionEffect } from "@/permission/service"
|
import { PermissionNext } from "@/permission"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { VcsService } from "@/project/vcs"
|
import { Vcs } from "@/project/vcs"
|
||||||
import { ProviderAuthService } from "@/provider/auth-service"
|
import { ProviderAuth } from "@/provider/auth"
|
||||||
import { QuestionService } from "@/question/service"
|
import { Question } from "@/question"
|
||||||
import { SkillService } from "@/skill/skill"
|
import { Skill } from "@/skill/skill"
|
||||||
import { SnapshotService } from "@/snapshot"
|
import { Snapshot } from "@/snapshot"
|
||||||
import { InstanceContext } from "./instance-context"
|
import { InstanceContext } from "./instance-context"
|
||||||
import { registerDisposer } from "./instance-registry"
|
import { registerDisposer } from "./instance-registry"
|
||||||
|
|
||||||
export { InstanceContext } from "./instance-context"
|
export { InstanceContext } from "./instance-context"
|
||||||
|
|
||||||
export type InstanceServices =
|
export type InstanceServices =
|
||||||
| QuestionService
|
| Question.Service
|
||||||
| PermissionEffect.Service
|
| PermissionNext.Service
|
||||||
| ProviderAuthService
|
| ProviderAuth.Service
|
||||||
| FileWatcherService
|
| FileWatcher.Service
|
||||||
| VcsService
|
| Vcs.Service
|
||||||
| FileTimeService
|
| FileTime.Service
|
||||||
| FormatService
|
| Format.Service
|
||||||
| FileService
|
| File.Service
|
||||||
| SkillService
|
| Skill.Service
|
||||||
| SnapshotService
|
| Snapshot.Service
|
||||||
|
|
||||||
// NOTE: LayerMap only passes the key (directory string) to lookup, but we need
|
// NOTE: LayerMap only passes the key (directory string) to lookup, but we need
|
||||||
// the full instance context (directory, worktree, project). We read from the
|
// the full instance context (directory, worktree, project). We read from the
|
||||||
@@ -36,16 +36,16 @@ export type InstanceServices =
|
|||||||
function lookup(_key: string) {
|
function lookup(_key: string) {
|
||||||
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current))
|
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current))
|
||||||
return Layer.mergeAll(
|
return Layer.mergeAll(
|
||||||
Layer.fresh(QuestionService.layer),
|
Layer.fresh(Question.layer),
|
||||||
Layer.fresh(PermissionEffect.layer),
|
Layer.fresh(PermissionNext.layer),
|
||||||
Layer.fresh(ProviderAuthService.layer),
|
Layer.fresh(ProviderAuth.defaultLayer),
|
||||||
Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie),
|
Layer.fresh(FileWatcher.layer).pipe(Layer.orDie),
|
||||||
Layer.fresh(VcsService.layer),
|
Layer.fresh(Vcs.layer),
|
||||||
Layer.fresh(FileTimeService.layer).pipe(Layer.orDie),
|
Layer.fresh(FileTime.layer).pipe(Layer.orDie),
|
||||||
Layer.fresh(FormatService.layer),
|
Layer.fresh(Format.layer),
|
||||||
Layer.fresh(FileService.layer),
|
Layer.fresh(File.layer),
|
||||||
Layer.fresh(SkillService.layer),
|
Layer.fresh(Skill.defaultLayer),
|
||||||
Layer.fresh(SnapshotService.layer),
|
Layer.fresh(Snapshot.defaultLayer),
|
||||||
).pipe(Layer.provide(ctx))
|
).pipe(Layer.provide(ctx))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,9 +55,7 @@ export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<s
|
|||||||
static readonly layer = Layer.effect(
|
static readonly layer = Layer.effect(
|
||||||
Instances,
|
Instances,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const layerMap = yield* LayerMap.make(lookup, {
|
const layerMap = yield* LayerMap.make(lookup, { idleTimeToLive: Infinity })
|
||||||
idleTimeToLive: Infinity,
|
|
||||||
})
|
|
||||||
const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory)))
|
const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory)))
|
||||||
yield* Effect.addFinalizer(() => Effect.sync(unregister))
|
yield* Effect.addFinalizer(() => Effect.sync(unregister))
|
||||||
return Instances.of(layerMap)
|
return Instances.of(layerMap)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||||
import { AccountService } from "@/account/service"
|
import { AccountEffect } from "@/account/effect"
|
||||||
import { AuthService } from "@/auth/service"
|
import { AuthEffect } from "@/auth/effect"
|
||||||
import { Instances } from "@/effect/instances"
|
import { Instances } from "@/effect/instances"
|
||||||
import type { InstanceServices } from "@/effect/instances"
|
import type { InstanceServices } from "@/effect/instances"
|
||||||
import { TruncateEffect } from "@/tool/truncate-effect"
|
import { TruncateEffect } from "@/tool/truncate-effect"
|
||||||
@@ -8,10 +8,10 @@ import { Instance } from "@/project/instance"
|
|||||||
|
|
||||||
export const runtime = ManagedRuntime.make(
|
export const runtime = ManagedRuntime.make(
|
||||||
Layer.mergeAll(
|
Layer.mergeAll(
|
||||||
AccountService.defaultLayer, //
|
AccountEffect.defaultLayer, //
|
||||||
TruncateEffect.defaultLayer,
|
TruncateEffect.defaultLayer,
|
||||||
Instances.layer,
|
Instances.layer,
|
||||||
).pipe(Layer.provideMerge(AuthService.defaultLayer)),
|
).pipe(Layer.provideMerge(AuthEffect.layer)),
|
||||||
)
|
)
|
||||||
|
|
||||||
export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {
|
export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {
|
||||||
|
|||||||
@@ -1,272 +1,20 @@
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import z from "zod"
|
|
||||||
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 { Global } from "../global"
|
|
||||||
import { git } from "@/util/git"
|
|
||||||
import { Protected } from "./protected"
|
|
||||||
import { InstanceContext } from "@/effect/instance-context"
|
import { InstanceContext } from "@/effect/instance-context"
|
||||||
import { Effect, Layer, ServiceMap } from "effect"
|
|
||||||
import { runPromiseInstance } from "@/effect/runtime"
|
import { runPromiseInstance } from "@/effect/runtime"
|
||||||
|
import { git } from "@/util/git"
|
||||||
const log = Log.create({ service: "file" })
|
import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
|
||||||
|
import { formatPatch, structuredPatch } from "diff"
|
||||||
const binaryExtensions = new Set([
|
import fs from "fs"
|
||||||
"exe",
|
import fuzzysort from "fuzzysort"
|
||||||
"dll",
|
import ignore from "ignore"
|
||||||
"pdb",
|
import path from "path"
|
||||||
"bin",
|
import z from "zod"
|
||||||
"so",
|
import { Global } from "../global"
|
||||||
"dylib",
|
import { Instance } from "../project/instance"
|
||||||
"o",
|
import { Filesystem } from "../util/filesystem"
|
||||||
"a",
|
import { Log } from "../util/log"
|
||||||
"lib",
|
import { Protected } from "./protected"
|
||||||
"wav",
|
import { Ripgrep } from "./ripgrep"
|
||||||
"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<string, string> = {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
export namespace File {
|
export namespace File {
|
||||||
export const Info = z
|
export const Info = z
|
||||||
@@ -336,28 +84,270 @@ export namespace File {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function init() {
|
export function init() {
|
||||||
return runPromiseInstance(FileService.use((s) => s.init()))
|
return runPromiseInstance(Service.use((svc) => svc.init()))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function status() {
|
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<Content> {
|
export async function read(file: string): Promise<Content> {
|
||||||
return runPromiseInstance(FileService.use((s) => s.read(file)))
|
return runPromiseInstance(Service.use((svc) => svc.read(file)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function list(dir?: string) {
|
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" }) {
|
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)))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export namespace FileService {
|
const log = Log.create({ service: "file" })
|
||||||
export interface Service {
|
|
||||||
|
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<string, string> = {
|
||||||
|
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 interface Interface {
|
||||||
readonly init: () => Effect.Effect<void>
|
readonly init: () => Effect.Effect<void>
|
||||||
readonly status: () => Effect.Effect<File.Info[]>
|
readonly status: () => Effect.Effect<File.Info[]>
|
||||||
readonly read: (file: string) => Effect.Effect<File.Content>
|
readonly read: (file: string) => Effect.Effect<File.Content>
|
||||||
@@ -369,89 +359,83 @@ export namespace FileService {
|
|||||||
type?: "file" | "directory"
|
type?: "file" | "directory"
|
||||||
}) => Effect.Effect<string[]>
|
}) => Effect.Effect<string[]>
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export class FileService extends ServiceMap.Service<FileService, FileService.Service>()("@opencode/File") {
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/File") {}
|
||||||
static readonly layer = Layer.effect(
|
|
||||||
FileService,
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const instance = yield* InstanceContext
|
const instance = yield* InstanceContext
|
||||||
|
|
||||||
// File cache state
|
|
||||||
type Entry = { files: string[]; dirs: string[] }
|
|
||||||
let cache: Entry = { files: [], dirs: [] }
|
let cache: Entry = { files: [], dirs: [] }
|
||||||
let task: Promise<void> | undefined
|
|
||||||
|
|
||||||
const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global"
|
const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global"
|
||||||
|
|
||||||
function kick() {
|
const scan = Effect.fn("File.scan")(function* () {
|
||||||
if (task) return task
|
if (instance.directory === path.parse(instance.directory).root) return
|
||||||
task = (async () => {
|
const next: Entry = { files: [], dirs: [] }
|
||||||
// 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<string>()
|
|
||||||
const protectedNames = Protected.names()
|
|
||||||
|
|
||||||
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
|
yield* Effect.promise(async () => {
|
||||||
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
|
if (isGlobalHome) {
|
||||||
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
|
const dirs = new Set<string>()
|
||||||
|
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[])
|
||||||
|
|
||||||
const top = await fs.promises
|
for (const entry of top) {
|
||||||
.readdir(instance.directory, { withFileTypes: true })
|
if (!entry.isDirectory()) continue
|
||||||
.catch(() => [] as fs.Dirent[])
|
if (shouldIgnoreName(entry.name)) continue
|
||||||
|
dirs.add(entry.name + "/")
|
||||||
|
|
||||||
for (const entry of top) {
|
const base = path.join(instance.directory, entry.name)
|
||||||
if (!entry.isDirectory()) continue
|
const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
|
||||||
if (shouldIgnoreName(entry.name)) continue
|
for (const child of children) {
|
||||||
dirs.add(entry.name + "/")
|
if (!child.isDirectory()) continue
|
||||||
|
if (shouldIgnoreNested(child.name)) continue
|
||||||
const base = path.join(instance.directory, entry.name)
|
dirs.add(entry.name + "/" + child.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
|
next.dirs = Array.from(dirs).toSorted()
|
||||||
dirs.add(entry.name + "/" + child.name + "/")
|
} else {
|
||||||
}
|
const seen = new Set<string>()
|
||||||
}
|
for await (const file of Ripgrep.files({ cwd: instance.directory })) {
|
||||||
|
next.files.push(file)
|
||||||
next.dirs = Array.from(dirs).toSorted()
|
let current = file
|
||||||
} else {
|
while (true) {
|
||||||
const set = new Set<string>()
|
const dir = path.dirname(current)
|
||||||
for await (const file of Ripgrep.files({ cwd: instance.directory })) {
|
if (dir === ".") break
|
||||||
next.files.push(file)
|
if (dir === current) break
|
||||||
let current = file
|
current = dir
|
||||||
while (true) {
|
if (seen.has(dir)) continue
|
||||||
const dir = path.dirname(current)
|
seen.add(dir)
|
||||||
if (dir === ".") break
|
next.dirs.push(dir + "/")
|
||||||
if (dir === current) break
|
|
||||||
current = dir
|
|
||||||
if (set.has(dir)) continue
|
|
||||||
set.add(dir)
|
|
||||||
next.dirs.push(dir + "/")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cache = next
|
|
||||||
} finally {
|
|
||||||
task = undefined
|
|
||||||
}
|
}
|
||||||
})()
|
})
|
||||||
return task
|
|
||||||
}
|
|
||||||
|
|
||||||
const getFiles = async () => {
|
cache = next
|
||||||
void kick()
|
|
||||||
return cache
|
|
||||||
}
|
|
||||||
|
|
||||||
const init = Effect.fn("FileService.init")(function* () {
|
|
||||||
yield* Effect.promise(() => kick())
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const status = Effect.fn("FileService.status")(function* () {
|
const getFiles = () => cache
|
||||||
|
|
||||||
|
const scope = yield* Scope.Scope
|
||||||
|
let fiber: Fiber.Fiber<void> | 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("File.status")(function* () {
|
||||||
if (instance.project.vcs !== "git") return []
|
if (instance.project.vcs !== "git") return []
|
||||||
|
|
||||||
return yield* Effect.promise(async () => {
|
return yield* Effect.promise(async () => {
|
||||||
@@ -461,14 +445,13 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
|
|||||||
})
|
})
|
||||||
).text()
|
).text()
|
||||||
|
|
||||||
const changedFiles: File.Info[] = []
|
const changed: File.Info[] = []
|
||||||
|
|
||||||
if (diffOutput.trim()) {
|
if (diffOutput.trim()) {
|
||||||
const lines = diffOutput.trim().split("\n")
|
for (const line of diffOutput.trim().split("\n")) {
|
||||||
for (const line of lines) {
|
const [added, removed, file] = line.split("\t")
|
||||||
const [added, removed, filepath] = line.split("\t")
|
changed.push({
|
||||||
changedFiles.push({
|
path: file,
|
||||||
path: filepath,
|
|
||||||
added: added === "-" ? 0 : parseInt(added, 10),
|
added: added === "-" ? 0 : parseInt(added, 10),
|
||||||
removed: removed === "-" ? 0 : parseInt(removed, 10),
|
removed: removed === "-" ? 0 : parseInt(removed, 10),
|
||||||
status: "modified",
|
status: "modified",
|
||||||
@@ -494,14 +477,12 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
|
|||||||
).text()
|
).text()
|
||||||
|
|
||||||
if (untrackedOutput.trim()) {
|
if (untrackedOutput.trim()) {
|
||||||
const untrackedFiles = untrackedOutput.trim().split("\n")
|
for (const file of untrackedOutput.trim().split("\n")) {
|
||||||
for (const filepath of untrackedFiles) {
|
|
||||||
try {
|
try {
|
||||||
const content = await Filesystem.readText(path.join(instance.directory, filepath))
|
const content = await Filesystem.readText(path.join(instance.directory, file))
|
||||||
const lines = content.split("\n").length
|
changed.push({
|
||||||
changedFiles.push({
|
path: file,
|
||||||
path: filepath,
|
added: content.split("\n").length,
|
||||||
added: lines,
|
|
||||||
removed: 0,
|
removed: 0,
|
||||||
status: "added",
|
status: "added",
|
||||||
})
|
})
|
||||||
@@ -511,7 +492,6 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get deleted files
|
|
||||||
const deletedOutput = (
|
const deletedOutput = (
|
||||||
await git(
|
await git(
|
||||||
[
|
[
|
||||||
@@ -531,50 +511,51 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
|
|||||||
).text()
|
).text()
|
||||||
|
|
||||||
if (deletedOutput.trim()) {
|
if (deletedOutput.trim()) {
|
||||||
const deletedFiles = deletedOutput.trim().split("\n")
|
for (const file of deletedOutput.trim().split("\n")) {
|
||||||
for (const filepath of deletedFiles) {
|
changed.push({
|
||||||
changedFiles.push({
|
path: file,
|
||||||
path: filepath,
|
|
||||||
added: 0,
|
added: 0,
|
||||||
removed: 0, // Could get original line count but would require another git command
|
removed: 0,
|
||||||
status: "deleted",
|
status: "deleted",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return changedFiles.map((x) => {
|
return changed.map((item) => {
|
||||||
const full = path.isAbsolute(x.path) ? x.path : path.join(instance.directory, x.path)
|
const full = path.isAbsolute(item.path) ? item.path : path.join(instance.directory, item.path)
|
||||||
return {
|
return {
|
||||||
...x,
|
...item,
|
||||||
path: path.relative(instance.directory, full),
|
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<File.Content> => {
|
return yield* Effect.promise(async (): Promise<File.Content> => {
|
||||||
using _ = log.time("read", { file })
|
using _ = log.time("read", { file })
|
||||||
const full = path.join(instance.directory, file)
|
const full = path.join(instance.directory, file)
|
||||||
|
|
||||||
if (!Instance.containsPath(full)) {
|
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 (isImageByExtension(file)) {
|
||||||
if (await Filesystem.exists(full)) {
|
if (await Filesystem.exists(full)) {
|
||||||
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
|
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
|
||||||
const content = buffer.toString("base64")
|
return {
|
||||||
const mimeType = getImageMimeType(file)
|
type: "text",
|
||||||
return { type: "text", content, mimeType, encoding: "base64" }
|
content: buffer.toString("base64"),
|
||||||
|
mimeType: getImageMimeType(file),
|
||||||
|
encoding: "base64",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { type: "text", content: "" }
|
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: "" }
|
return { type: "binary", content: "" }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -583,7 +564,7 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mimeType = Filesystem.mimeType(full)
|
const mimeType = Filesystem.mimeType(full)
|
||||||
const encode = text ? false : shouldEncode(mimeType)
|
const encode = knownText ? false : shouldEncode(mimeType)
|
||||||
|
|
||||||
if (encode && !isImage(mimeType)) {
|
if (encode && !isImage(mimeType)) {
|
||||||
return { type: "binary", content: "", mimeType }
|
return { type: "binary", content: "", mimeType }
|
||||||
@@ -591,8 +572,12 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
|
|||||||
|
|
||||||
if (encode) {
|
if (encode) {
|
||||||
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
|
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
|
||||||
const content = buffer.toString("base64")
|
return {
|
||||||
return { type: "text", content, mimeType, encoding: "base64" }
|
type: "text",
|
||||||
|
content: buffer.toString("base64"),
|
||||||
|
mimeType,
|
||||||
|
encoding: "base64",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = (await Filesystem.readText(full).catch(() => "")).trim()
|
const content = (await Filesystem.readText(full).catch(() => "")).trim()
|
||||||
@@ -603,7 +588,9 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
|
|||||||
).text()
|
).text()
|
||||||
if (!diff.trim()) {
|
if (!diff.trim()) {
|
||||||
diff = (
|
diff = (
|
||||||
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { cwd: instance.directory })
|
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
|
||||||
|
cwd: instance.directory,
|
||||||
|
})
|
||||||
).text()
|
).text()
|
||||||
}
|
}
|
||||||
if (diff.trim()) {
|
if (diff.trim()) {
|
||||||
@@ -612,64 +599,64 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
|
|||||||
context: Infinity,
|
context: Infinity,
|
||||||
ignoreWhitespace: true,
|
ignoreWhitespace: true,
|
||||||
})
|
})
|
||||||
const diff = formatPatch(patch)
|
return {
|
||||||
return { type: "text", content, patch, diff }
|
type: "text",
|
||||||
|
content,
|
||||||
|
patch,
|
||||||
|
diff: formatPatch(patch),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { type: "text", content }
|
return { type: "text", content }
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const list = Effect.fn("FileService.list")(function* (dir?: string) {
|
const list = Effect.fn("File.list")(function* (dir?: string) {
|
||||||
return yield* Effect.promise(async () => {
|
return yield* Effect.promise(async () => {
|
||||||
const exclude = [".git", ".DS_Store"]
|
const exclude = [".git", ".DS_Store"]
|
||||||
let ignored = (_: string) => false
|
let ignored = (_: string) => false
|
||||||
if (instance.project.vcs === "git") {
|
if (instance.project.vcs === "git") {
|
||||||
const ig = ignore()
|
const ig = ignore()
|
||||||
const gitignorePath = path.join(instance.project.worktree, ".gitignore")
|
const gitignore = path.join(instance.project.worktree, ".gitignore")
|
||||||
if (await Filesystem.exists(gitignorePath)) {
|
if (await Filesystem.exists(gitignore)) {
|
||||||
ig.add(await Filesystem.readText(gitignorePath))
|
ig.add(await Filesystem.readText(gitignore))
|
||||||
}
|
}
|
||||||
const ignorePath = path.join(instance.project.worktree, ".ignore")
|
const ignoreFile = path.join(instance.project.worktree, ".ignore")
|
||||||
if (await Filesystem.exists(ignorePath)) {
|
if (await Filesystem.exists(ignoreFile)) {
|
||||||
ig.add(await Filesystem.readText(ignorePath))
|
ig.add(await Filesystem.readText(ignoreFile))
|
||||||
}
|
}
|
||||||
ignored = ig.ignores.bind(ig)
|
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)) {
|
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[] = []
|
const nodes: File.Node[] = []
|
||||||
for (const entry of await fs.promises
|
for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) {
|
||||||
.readdir(resolved, {
|
|
||||||
withFileTypes: true,
|
|
||||||
})
|
|
||||||
.catch(() => [])) {
|
|
||||||
if (exclude.includes(entry.name)) continue
|
if (exclude.includes(entry.name)) continue
|
||||||
const fullPath = path.join(resolved, entry.name)
|
const absolute = path.join(resolved, entry.name)
|
||||||
const relativePath = path.relative(instance.directory, fullPath)
|
const file = path.relative(instance.directory, absolute)
|
||||||
const type = entry.isDirectory() ? "directory" : "file"
|
const type = entry.isDirectory() ? "directory" : "file"
|
||||||
nodes.push({
|
nodes.push({
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
path: relativePath,
|
path: file,
|
||||||
absolute: fullPath,
|
absolute,
|
||||||
type,
|
type,
|
||||||
ignored: ignored(type === "directory" ? relativePath + "/" : relativePath),
|
ignored: ignored(type === "directory" ? file + "/" : file),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return nodes.sort((a, b) => {
|
return nodes.sort((a, b) => {
|
||||||
if (a.type !== b.type) {
|
if (a.type !== b.type) return a.type === "directory" ? -1 : 1
|
||||||
return a.type === "directory" ? -1 : 1
|
|
||||||
}
|
|
||||||
return a.name.localeCompare(b.name)
|
return a.name.localeCompare(b.name)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const search = Effect.fn("FileService.search")(function* (input: {
|
const search = Effect.fn("File.search")(function* (input: {
|
||||||
query: string
|
query: string
|
||||||
limit?: number
|
limit?: number
|
||||||
dirs?: boolean
|
dirs?: boolean
|
||||||
@@ -681,35 +668,20 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
|
|||||||
const kind = input.type ?? (input.dirs === false ? "file" : "all")
|
const kind = input.type ?? (input.dirs === false ? "file" : "all")
|
||||||
log.info("search", { query, kind })
|
log.info("search", { query, kind })
|
||||||
|
|
||||||
const result = await getFiles()
|
const result = getFiles()
|
||||||
|
|
||||||
const hidden = (item: string) => {
|
|
||||||
const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
|
|
||||||
return normalized.split("/").some((p) => p.startsWith(".") && p.length > 1)
|
|
||||||
}
|
|
||||||
const preferHidden = query.startsWith(".") || query.includes("/.")
|
const preferHidden = query.startsWith(".") || query.includes("/.")
|
||||||
const sortHiddenLast = (items: string[]) => {
|
|
||||||
if (preferHidden) return items
|
|
||||||
const visible: string[] = []
|
|
||||||
const hiddenItems: string[] = []
|
|
||||||
for (const item of items) {
|
|
||||||
const isHidden = hidden(item)
|
|
||||||
if (isHidden) hiddenItems.push(item)
|
|
||||||
if (!isHidden) visible.push(item)
|
|
||||||
}
|
|
||||||
return [...visible, ...hiddenItems]
|
|
||||||
}
|
|
||||||
if (!query) {
|
if (!query) {
|
||||||
if (kind === "file") return result.files.slice(0, limit)
|
if (kind === "file") return result.files.slice(0, limit)
|
||||||
return sortHiddenLast(result.dirs.toSorted()).slice(0, limit)
|
return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
const items =
|
const items =
|
||||||
kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
|
kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
|
||||||
|
|
||||||
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
|
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
|
||||||
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((r) => r.target)
|
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
|
||||||
const output = kind === "directory" ? sortHiddenLast(sorted).slice(0, limit) : sorted
|
const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
|
||||||
|
|
||||||
log.info("search", { query, kind, results: output.length })
|
log.info("search", { query, kind, results: output.length })
|
||||||
return output
|
return output
|
||||||
@@ -717,8 +689,7 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
|
|||||||
})
|
})
|
||||||
|
|
||||||
log.info("init")
|
log.info("init")
|
||||||
|
return Service.of({ init, status, read, list, search })
|
||||||
return FileService.of({ init, status, read, list, search })
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,115 +1,110 @@
|
|||||||
import { Log } from "../util/log"
|
import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
|
||||||
import { Flag } from "@/flag/flag"
|
|
||||||
import { Filesystem } from "../util/filesystem"
|
|
||||||
import { Effect, Layer, ServiceMap, Semaphore } from "effect"
|
|
||||||
import { runPromiseInstance } from "@/effect/runtime"
|
import { runPromiseInstance } from "@/effect/runtime"
|
||||||
|
import { Flag } from "@/flag/flag"
|
||||||
import type { SessionID } from "@/session/schema"
|
import type { SessionID } from "@/session/schema"
|
||||||
|
import { Filesystem } from "../util/filesystem"
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
|
||||||
const log = Log.create({ service: "file.time" })
|
export namespace FileTime {
|
||||||
|
const log = Log.create({ service: "file.time" })
|
||||||
|
|
||||||
export namespace FileTimeService {
|
export type Stamp = {
|
||||||
export interface Service {
|
readonly read: Date
|
||||||
|
readonly mtime: number | undefined
|
||||||
|
readonly ctime: number | undefined
|
||||||
|
readonly size: number | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const stamp = Effect.fnUntraced(function* (file: string) {
|
||||||
|
const stat = Filesystem.stat(file)
|
||||||
|
const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
|
||||||
|
return {
|
||||||
|
read: yield* DateTime.nowAsDate,
|
||||||
|
mtime: stat?.mtime?.getTime(),
|
||||||
|
ctime: stat?.ctime?.getTime(),
|
||||||
|
size,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
|
||||||
|
const value = reads.get(sessionID)
|
||||||
|
if (value) return value
|
||||||
|
|
||||||
|
const next = new Map<string, Stamp>()
|
||||||
|
reads.set(sessionID, next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
|
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
|
||||||
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
|
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
|
||||||
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
|
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
|
||||||
readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
|
readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
type Stamp = {
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
|
||||||
readonly read: Date
|
|
||||||
readonly mtime: number | undefined
|
|
||||||
readonly ctime: number | undefined
|
|
||||||
readonly size: number | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function stamp(file: string): Stamp {
|
export const layer = Layer.effect(
|
||||||
const stat = Filesystem.stat(file)
|
Service,
|
||||||
const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
|
|
||||||
return {
|
|
||||||
read: new Date(),
|
|
||||||
mtime: stat?.mtime?.getTime(),
|
|
||||||
ctime: stat?.ctime?.getTime(),
|
|
||||||
size,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function session(reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) {
|
|
||||||
let value = reads.get(sessionID)
|
|
||||||
if (!value) {
|
|
||||||
value = new Map<string, Stamp>()
|
|
||||||
reads.set(sessionID, value)
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FileTimeService extends ServiceMap.Service<FileTimeService, FileTimeService.Service>()(
|
|
||||||
"@opencode/FileTime",
|
|
||||||
) {
|
|
||||||
static readonly layer = Layer.effect(
|
|
||||||
FileTimeService,
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
|
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
|
||||||
const reads = new Map<SessionID, Map<string, Stamp>>()
|
const reads = new Map<SessionID, Map<string, Stamp>>()
|
||||||
const locks = new Map<string, Semaphore.Semaphore>()
|
const locks = new Map<string, Semaphore.Semaphore>()
|
||||||
|
|
||||||
function getLock(filepath: string) {
|
const getLock = (filepath: string) => {
|
||||||
let lock = locks.get(filepath)
|
const lock = locks.get(filepath)
|
||||||
if (!lock) {
|
if (lock) return lock
|
||||||
lock = Semaphore.makeUnsafe(1)
|
|
||||||
locks.set(filepath, lock)
|
const next = Semaphore.makeUnsafe(1)
|
||||||
}
|
locks.set(filepath, next)
|
||||||
return lock
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
return FileTimeService.of({
|
const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
|
||||||
read: Effect.fn("FileTimeService.read")(function* (sessionID: SessionID, file: string) {
|
log.info("read", { sessionID, file })
|
||||||
log.info("read", { sessionID, file })
|
session(reads, sessionID).set(file, yield* stamp(file))
|
||||||
session(reads, sessionID).set(file, stamp(file))
|
|
||||||
}),
|
|
||||||
|
|
||||||
get: Effect.fn("FileTimeService.get")(function* (sessionID: SessionID, file: string) {
|
|
||||||
return reads.get(sessionID)?.get(file)?.read
|
|
||||||
}),
|
|
||||||
|
|
||||||
assert: Effect.fn("FileTimeService.assert")(function* (sessionID: SessionID, filepath: string) {
|
|
||||||
if (disableCheck) return
|
|
||||||
|
|
||||||
const time = reads.get(sessionID)?.get(filepath)
|
|
||||||
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
|
|
||||||
const next = stamp(filepath)
|
|
||||||
const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
|
|
||||||
|
|
||||||
if (changed) {
|
|
||||||
throw new Error(
|
|
||||||
`File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
|
|
||||||
withLock: Effect.fn("FileTimeService.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
|
|
||||||
const lock = getLock(filepath)
|
|
||||||
return yield* Effect.promise(fn).pipe(lock.withPermits(1))
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
|
||||||
|
return reads.get(sessionID)?.get(file)?.read
|
||||||
|
})
|
||||||
|
|
||||||
|
const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
|
||||||
|
if (disableCheck) return
|
||||||
|
|
||||||
|
const time = reads.get(sessionID)?.get(filepath)
|
||||||
|
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
|
||||||
|
|
||||||
|
const next = yield* stamp(filepath)
|
||||||
|
const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
|
||||||
|
if (!changed) return
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
|
||||||
|
return yield* Effect.promise(fn).pipe(getLock(filepath).withPermits(1))
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ read, get, assert, withLock })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
export namespace FileTime {
|
|
||||||
export function read(sessionID: SessionID, file: string) {
|
export function read(sessionID: SessionID, file: string) {
|
||||||
return runPromiseInstance(FileTimeService.use((s) => s.read(sessionID, file)))
|
return runPromiseInstance(Service.use((s) => s.read(sessionID, file)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function get(sessionID: SessionID, file: string) {
|
export function get(sessionID: SessionID, file: string) {
|
||||||
return runPromiseInstance(FileTimeService.use((s) => s.get(sessionID, file)))
|
return runPromiseInstance(Service.use((s) => s.get(sessionID, file)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function assert(sessionID: SessionID, filepath: string) {
|
export async function assert(sessionID: SessionID, filepath: string) {
|
||||||
return runPromiseInstance(FileTimeService.use((s) => s.assert(sessionID, filepath)))
|
return runPromiseInstance(Service.use((s) => s.assert(sessionID, filepath)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
|
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
|
||||||
return runPromiseInstance(FileTimeService.use((s) => s.withLock(filepath, fn)))
|
return runPromiseInstance(Service.use((s) => s.withLock(filepath, fn)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,89 +1,76 @@
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
import { Cause, Effect, Layer, ServiceMap } from "effect"
|
||||||
import { Bus } from "@/bus"
|
|
||||||
import { InstanceContext } from "@/effect/instance-context"
|
|
||||||
import { Instance } from "@/project/instance"
|
|
||||||
import z from "zod"
|
|
||||||
import { Log } from "../util/log"
|
|
||||||
import { FileIgnore } from "./ignore"
|
|
||||||
import { Config } from "../config/config"
|
|
||||||
import path from "path"
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { createWrapper } from "@parcel/watcher/wrapper"
|
import { createWrapper } from "@parcel/watcher/wrapper"
|
||||||
import { lazy } from "@/util/lazy"
|
|
||||||
import type ParcelWatcher from "@parcel/watcher"
|
import type ParcelWatcher from "@parcel/watcher"
|
||||||
import { readdir } from "fs/promises"
|
import { readdir } from "fs/promises"
|
||||||
import { git } from "@/util/git"
|
import path from "path"
|
||||||
import { Protected } from "./protected"
|
import z from "zod"
|
||||||
|
import { Bus } from "@/bus"
|
||||||
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
|
import { InstanceContext } from "@/effect/instance-context"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { Cause, Effect, Layer, ServiceMap } from "effect"
|
import { Instance } from "@/project/instance"
|
||||||
|
import { git } from "@/util/git"
|
||||||
const SUBSCRIBE_TIMEOUT_MS = 10_000
|
import { lazy } from "@/util/lazy"
|
||||||
|
import { Config } from "../config/config"
|
||||||
|
import { FileIgnore } from "./ignore"
|
||||||
|
import { Protected } from "./protected"
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
|
||||||
declare const OPENCODE_LIBC: string | undefined
|
declare const OPENCODE_LIBC: string | undefined
|
||||||
|
|
||||||
const log = Log.create({ service: "file.watcher" })
|
|
||||||
|
|
||||||
const event = {
|
|
||||||
Updated: BusEvent.define(
|
|
||||||
"file.watcher.updated",
|
|
||||||
z.object({
|
|
||||||
file: z.string(),
|
|
||||||
event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
|
|
||||||
try {
|
|
||||||
const binding = require(
|
|
||||||
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
|
|
||||||
)
|
|
||||||
return createWrapper(binding) as typeof import("@parcel/watcher")
|
|
||||||
} catch (error) {
|
|
||||||
log.error("failed to load watcher binding", { error })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function getBackend() {
|
|
||||||
if (process.platform === "win32") return "windows"
|
|
||||||
if (process.platform === "darwin") return "fs-events"
|
|
||||||
if (process.platform === "linux") return "inotify"
|
|
||||||
}
|
|
||||||
|
|
||||||
export namespace FileWatcher {
|
export namespace FileWatcher {
|
||||||
export const Event = event
|
const log = Log.create({ service: "file.watcher" })
|
||||||
/** Whether the native @parcel/watcher binding is available on this platform. */
|
const SUBSCRIBE_TIMEOUT_MS = 10_000
|
||||||
export const hasNativeBinding = () => !!watcher()
|
|
||||||
}
|
|
||||||
|
|
||||||
const init = Effect.fn("FileWatcherService.init")(function* () {})
|
export const Event = {
|
||||||
|
Updated: BusEvent.define(
|
||||||
export namespace FileWatcherService {
|
"file.watcher.updated",
|
||||||
export interface Service {
|
z.object({
|
||||||
readonly init: () => Effect.Effect<void>
|
file: z.string(),
|
||||||
|
event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]),
|
||||||
|
}),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export class FileWatcherService extends ServiceMap.Service<FileWatcherService, FileWatcherService.Service>()(
|
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
|
||||||
"@opencode/FileWatcher",
|
try {
|
||||||
) {
|
const binding = require(
|
||||||
static readonly layer = Layer.effect(
|
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
|
||||||
FileWatcherService,
|
)
|
||||||
|
return createWrapper(binding) as typeof import("@parcel/watcher")
|
||||||
|
} catch (error) {
|
||||||
|
log.error("failed to load watcher binding", { error })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function getBackend() {
|
||||||
|
if (process.platform === "win32") return "windows"
|
||||||
|
if (process.platform === "darwin") return "fs-events"
|
||||||
|
if (process.platform === "linux") return "inotify"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hasNativeBinding = () => !!watcher()
|
||||||
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, {}>()("@opencode/FileWatcher") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const instance = yield* InstanceContext
|
const instance = yield* InstanceContext
|
||||||
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return FileWatcherService.of({ init })
|
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return Service.of({})
|
||||||
|
|
||||||
log.info("init", { directory: instance.directory })
|
log.info("init", { directory: instance.directory })
|
||||||
|
|
||||||
const backend = getBackend()
|
const backend = getBackend()
|
||||||
if (!backend) {
|
if (!backend) {
|
||||||
log.error("watcher backend not supported", { directory: instance.directory, platform: process.platform })
|
log.error("watcher backend not supported", { directory: instance.directory, platform: process.platform })
|
||||||
return FileWatcherService.of({ init })
|
return Service.of({})
|
||||||
}
|
}
|
||||||
|
|
||||||
const w = watcher()
|
const w = watcher()
|
||||||
if (!w) return FileWatcherService.of({ init })
|
if (!w) return Service.of({})
|
||||||
|
|
||||||
log.info("watcher backend", { directory: instance.directory, platform: process.platform, backend })
|
log.info("watcher backend", { directory: instance.directory, platform: process.platform, backend })
|
||||||
|
|
||||||
@@ -93,9 +80,9 @@ export class FileWatcherService extends ServiceMap.Service<FileWatcherService, F
|
|||||||
const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
|
const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
|
||||||
if (err) return
|
if (err) return
|
||||||
for (const evt of evts) {
|
for (const evt of evts) {
|
||||||
if (evt.type === "create") Bus.publish(event.Updated, { file: evt.path, event: "add" })
|
if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" })
|
||||||
if (evt.type === "update") Bus.publish(event.Updated, { file: evt.path, event: "change" })
|
if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" })
|
||||||
if (evt.type === "delete") Bus.publish(event.Updated, { file: evt.path, event: "unlink" })
|
if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -108,7 +95,6 @@ export class FileWatcherService extends ServiceMap.Service<FileWatcherService, F
|
|||||||
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
|
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
|
||||||
Effect.catchCause((cause) => {
|
Effect.catchCause((cause) => {
|
||||||
log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
|
log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
|
||||||
// Clean up a subscription that resolves after timeout
|
|
||||||
pending.then((s) => s.unsubscribe()).catch(() => {})
|
pending.then((s) => s.unsubscribe()).catch(() => {})
|
||||||
return Effect.void
|
return Effect.void
|
||||||
}),
|
}),
|
||||||
@@ -137,11 +123,11 @@ export class FileWatcherService extends ServiceMap.Service<FileWatcherService, F
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return FileWatcherService.of({ init })
|
return Service.of({})
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.catchCause((cause) => {
|
Effect.catchCause((cause) => {
|
||||||
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
|
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
|
||||||
return Effect.succeed(FileWatcherService.of({ init }))
|
return Effect.succeed(Service.of({}))
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,21 +1,20 @@
|
|||||||
import { Bus } from "../bus"
|
|
||||||
import { File } from "../file"
|
|
||||||
import { Log } from "../util/log"
|
|
||||||
import path from "path"
|
|
||||||
import z from "zod"
|
|
||||||
|
|
||||||
import * as Formatter from "./formatter"
|
|
||||||
import { Config } from "../config/config"
|
|
||||||
import { mergeDeep } from "remeda"
|
|
||||||
import { Instance } from "../project/instance"
|
|
||||||
import { Process } from "../util/process"
|
|
||||||
import { InstanceContext } from "@/effect/instance-context"
|
|
||||||
import { Effect, Layer, ServiceMap } from "effect"
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
import { runPromiseInstance } from "@/effect/runtime"
|
import { runPromiseInstance } from "@/effect/runtime"
|
||||||
|
import { InstanceContext } from "@/effect/instance-context"
|
||||||
const log = Log.create({ service: "format" })
|
import path from "path"
|
||||||
|
import { mergeDeep } from "remeda"
|
||||||
|
import z from "zod"
|
||||||
|
import { Bus } from "../bus"
|
||||||
|
import { Config } from "../config/config"
|
||||||
|
import { File } from "../file"
|
||||||
|
import { Instance } from "../project/instance"
|
||||||
|
import { Process } from "../util/process"
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
import * as Formatter from "./formatter"
|
||||||
|
|
||||||
export namespace Format {
|
export namespace Format {
|
||||||
|
const log = Log.create({ service: "format" })
|
||||||
|
|
||||||
export const Status = z
|
export const Status = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
@@ -27,25 +26,14 @@ export namespace Format {
|
|||||||
})
|
})
|
||||||
export type Status = z.infer<typeof Status>
|
export type Status = z.infer<typeof Status>
|
||||||
|
|
||||||
export async function init() {
|
export interface Interface {
|
||||||
return runPromiseInstance(FormatService.use((s) => s.init()))
|
readonly status: () => Effect.Effect<Status[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function status() {
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
|
||||||
return runPromiseInstance(FormatService.use((s) => s.status()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export namespace FormatService {
|
export const layer = Layer.effect(
|
||||||
export interface Service {
|
Service,
|
||||||
readonly init: () => Effect.Effect<void>
|
|
||||||
readonly status: () => Effect.Effect<Format.Status[]>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FormatService extends ServiceMap.Service<FormatService, FormatService.Service>()("@opencode/Format") {
|
|
||||||
static readonly layer = Layer.effect(
|
|
||||||
FormatService,
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const instance = yield* InstanceContext
|
const instance = yield* InstanceContext
|
||||||
|
|
||||||
@@ -63,17 +51,19 @@ export class FormatService extends ServiceMap.Service<FormatService, FormatServi
|
|||||||
delete formatters[name]
|
delete formatters[name]
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const result = mergeDeep(formatters[name] ?? {}, {
|
const info = mergeDeep(formatters[name] ?? {}, {
|
||||||
command: [],
|
command: [],
|
||||||
extensions: [],
|
extensions: [],
|
||||||
...item,
|
...item,
|
||||||
}) as Formatter.Info
|
})
|
||||||
|
|
||||||
if (result.command.length === 0) continue
|
if (info.command.length === 0) continue
|
||||||
|
|
||||||
result.enabled = async () => true
|
formatters[name] = {
|
||||||
result.name = name
|
...info,
|
||||||
formatters[name] = result
|
name,
|
||||||
|
enabled: async () => true,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.info("all formatters are disabled")
|
log.info("all formatters are disabled")
|
||||||
@@ -100,50 +90,52 @@ export class FormatService extends ServiceMap.Service<FormatService, FormatServi
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsubscribe = Bus.subscribe(
|
yield* Effect.acquireRelease(
|
||||||
File.Event.Edited,
|
Effect.sync(() =>
|
||||||
Instance.bind(async (payload) => {
|
Bus.subscribe(
|
||||||
const file = payload.properties.file
|
File.Event.Edited,
|
||||||
log.info("formatting", { file })
|
Instance.bind(async (payload) => {
|
||||||
const ext = path.extname(file)
|
const file = payload.properties.file
|
||||||
|
log.info("formatting", { file })
|
||||||
|
const ext = path.extname(file)
|
||||||
|
|
||||||
for (const item of await getFormatter(ext)) {
|
for (const item of await getFormatter(ext)) {
|
||||||
log.info("running", { command: item.command })
|
log.info("running", { command: item.command })
|
||||||
try {
|
try {
|
||||||
const proc = Process.spawn(
|
const proc = Process.spawn(
|
||||||
item.command.map((x) => x.replace("$FILE", file)),
|
item.command.map((x) => x.replace("$FILE", file)),
|
||||||
{
|
{
|
||||||
cwd: instance.directory,
|
cwd: instance.directory,
|
||||||
env: { ...process.env, ...item.environment },
|
env: { ...process.env, ...item.environment },
|
||||||
stdout: "ignore",
|
stdout: "ignore",
|
||||||
stderr: "ignore",
|
stderr: "ignore",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
const exit = await proc.exited
|
const exit = await proc.exited
|
||||||
if (exit !== 0)
|
if (exit !== 0) {
|
||||||
log.error("failed", {
|
log.error("failed", {
|
||||||
command: item.command,
|
command: item.command,
|
||||||
...item.environment,
|
...item.environment,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
}
|
||||||
log.error("failed to format file", {
|
} catch (error) {
|
||||||
error,
|
log.error("failed to format file", {
|
||||||
command: item.command,
|
error,
|
||||||
...item.environment,
|
command: item.command,
|
||||||
file,
|
...item.environment,
|
||||||
})
|
file,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}),
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(unsubscribe) => Effect.sync(unsubscribe),
|
||||||
)
|
)
|
||||||
|
|
||||||
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
|
|
||||||
log.info("init")
|
log.info("init")
|
||||||
|
|
||||||
const init = Effect.fn("FormatService.init")(function* () {})
|
const status = Effect.fn("Format.status")(function* () {
|
||||||
|
const result: Status[] = []
|
||||||
const status = Effect.fn("FormatService.status")(function* () {
|
|
||||||
const result: Format.Status[] = []
|
|
||||||
for (const formatter of Object.values(formatters)) {
|
for (const formatter of Object.values(formatters)) {
|
||||||
const isOn = yield* Effect.promise(() => isEnabled(formatter))
|
const isOn = yield* Effect.promise(() => isEnabled(formatter))
|
||||||
result.push({
|
result.push({
|
||||||
@@ -155,7 +147,11 @@ export class FormatService extends ServiceMap.Service<FormatService, FormatServi
|
|||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
return FormatService.of({ init, status })
|
return Service.of({ status })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export async function status() {
|
||||||
|
return runPromiseInstance(Service.use((s) => s.status()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,251 @@
|
|||||||
import { runPromiseInstance } from "@/effect/runtime"
|
import { runPromiseInstance } from "@/effect/runtime"
|
||||||
|
import { Bus } from "@/bus"
|
||||||
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
|
import { InstanceContext } from "@/effect/instance-context"
|
||||||
|
import { ProjectID } from "@/project/schema"
|
||||||
|
import { MessageID, SessionID } from "@/session/schema"
|
||||||
|
import { PermissionTable } from "@/session/session.sql"
|
||||||
|
import { Database, eq } from "@/storage/db"
|
||||||
import { fn } from "@/util/fn"
|
import { fn } from "@/util/fn"
|
||||||
|
import { Log } from "@/util/log"
|
||||||
import { Wildcard } from "@/util/wildcard"
|
import { Wildcard } from "@/util/wildcard"
|
||||||
|
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import { PermissionEffect as S } from "./service"
|
import z from "zod"
|
||||||
|
import { PermissionID } from "./schema"
|
||||||
|
|
||||||
export namespace PermissionNext {
|
export namespace PermissionNext {
|
||||||
|
const log = Log.create({ service: "permission" })
|
||||||
|
|
||||||
|
export const Action = z.enum(["allow", "deny", "ask"]).meta({
|
||||||
|
ref: "PermissionAction",
|
||||||
|
})
|
||||||
|
export type Action = z.infer<typeof Action>
|
||||||
|
|
||||||
|
export const Rule = z
|
||||||
|
.object({
|
||||||
|
permission: z.string(),
|
||||||
|
pattern: z.string(),
|
||||||
|
action: Action,
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "PermissionRule",
|
||||||
|
})
|
||||||
|
export type Rule = z.infer<typeof Rule>
|
||||||
|
|
||||||
|
export const Ruleset = Rule.array().meta({
|
||||||
|
ref: "PermissionRuleset",
|
||||||
|
})
|
||||||
|
export type Ruleset = z.infer<typeof Ruleset>
|
||||||
|
|
||||||
|
export const Request = z
|
||||||
|
.object({
|
||||||
|
id: PermissionID.zod,
|
||||||
|
sessionID: SessionID.zod,
|
||||||
|
permission: z.string(),
|
||||||
|
patterns: z.string().array(),
|
||||||
|
metadata: z.record(z.string(), z.any()),
|
||||||
|
always: z.string().array(),
|
||||||
|
tool: z
|
||||||
|
.object({
|
||||||
|
messageID: MessageID.zod,
|
||||||
|
callID: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "PermissionRequest",
|
||||||
|
})
|
||||||
|
export type Request = z.infer<typeof Request>
|
||||||
|
|
||||||
|
export const Reply = z.enum(["once", "always", "reject"])
|
||||||
|
export type Reply = z.infer<typeof Reply>
|
||||||
|
|
||||||
|
export const Approval = z.object({
|
||||||
|
projectID: ProjectID.zod,
|
||||||
|
patterns: z.string().array(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Event = {
|
||||||
|
Asked: BusEvent.define("permission.asked", Request),
|
||||||
|
Replied: BusEvent.define(
|
||||||
|
"permission.replied",
|
||||||
|
z.object({
|
||||||
|
sessionID: SessionID.zod,
|
||||||
|
requestID: PermissionID.zod,
|
||||||
|
reply: Reply,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
|
||||||
|
override get message() {
|
||||||
|
return "The user rejected permission to use this specific tool call."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
|
||||||
|
feedback: Schema.String,
|
||||||
|
}) {
|
||||||
|
override get message() {
|
||||||
|
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
|
||||||
|
ruleset: Schema.Any,
|
||||||
|
}) {
|
||||||
|
override get message() {
|
||||||
|
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Error = DeniedError | RejectedError | CorrectedError
|
||||||
|
|
||||||
|
export const AskInput = Request.partial({ id: true }).extend({
|
||||||
|
ruleset: Ruleset,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ReplyInput = z.object({
|
||||||
|
requestID: PermissionID.zod,
|
||||||
|
reply: Reply,
|
||||||
|
message: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error>
|
||||||
|
readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
|
||||||
|
readonly list: () => Effect.Effect<Request[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingEntry {
|
||||||
|
info: Request
|
||||||
|
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
||||||
|
const rules = rulesets.flat()
|
||||||
|
log.info("evaluate", { permission, pattern, ruleset: rules })
|
||||||
|
const match = rules.findLast(
|
||||||
|
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
|
||||||
|
)
|
||||||
|
return match ?? { action: "ask", permission, pattern: "*" }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/PermissionNext") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const { project } = yield* InstanceContext
|
||||||
|
const row = Database.use((db) =>
|
||||||
|
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, project.id)).get(),
|
||||||
|
)
|
||||||
|
const pending = new Map<PermissionID, PendingEntry>()
|
||||||
|
const approved: Ruleset = row?.data ?? []
|
||||||
|
|
||||||
|
const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) {
|
||||||
|
const { ruleset, ...request } = input
|
||||||
|
let needsAsk = false
|
||||||
|
|
||||||
|
for (const pattern of request.patterns) {
|
||||||
|
const rule = evaluate(request.permission, pattern, ruleset, approved)
|
||||||
|
log.info("evaluated", { permission: request.permission, pattern, action: rule })
|
||||||
|
if (rule.action === "deny") {
|
||||||
|
return yield* new DeniedError({
|
||||||
|
ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (rule.action === "allow") continue
|
||||||
|
needsAsk = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!needsAsk) return
|
||||||
|
|
||||||
|
const id = request.id ?? PermissionID.ascending()
|
||||||
|
const info: Request = {
|
||||||
|
id,
|
||||||
|
...request,
|
||||||
|
}
|
||||||
|
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
|
||||||
|
|
||||||
|
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
|
||||||
|
pending.set(id, { info, deferred })
|
||||||
|
void Bus.publish(Event.Asked, info)
|
||||||
|
return yield* Effect.ensuring(
|
||||||
|
Deferred.await(deferred),
|
||||||
|
Effect.sync(() => {
|
||||||
|
pending.delete(id)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) {
|
||||||
|
const existing = pending.get(input.requestID)
|
||||||
|
if (!existing) return
|
||||||
|
|
||||||
|
pending.delete(input.requestID)
|
||||||
|
void Bus.publish(Event.Replied, {
|
||||||
|
sessionID: existing.info.sessionID,
|
||||||
|
requestID: existing.info.id,
|
||||||
|
reply: input.reply,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (input.reply === "reject") {
|
||||||
|
yield* Deferred.fail(
|
||||||
|
existing.deferred,
|
||||||
|
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const [id, item] of pending.entries()) {
|
||||||
|
if (item.info.sessionID !== existing.info.sessionID) continue
|
||||||
|
pending.delete(id)
|
||||||
|
void Bus.publish(Event.Replied, {
|
||||||
|
sessionID: item.info.sessionID,
|
||||||
|
requestID: item.info.id,
|
||||||
|
reply: "reject",
|
||||||
|
})
|
||||||
|
yield* Deferred.fail(item.deferred, new RejectedError())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* Deferred.succeed(existing.deferred, undefined)
|
||||||
|
if (input.reply === "once") return
|
||||||
|
|
||||||
|
for (const pattern of existing.info.always) {
|
||||||
|
approved.push({
|
||||||
|
permission: existing.info.permission,
|
||||||
|
pattern,
|
||||||
|
action: "allow",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, item] of pending.entries()) {
|
||||||
|
if (item.info.sessionID !== existing.info.sessionID) continue
|
||||||
|
const ok = item.info.patterns.every(
|
||||||
|
(pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",
|
||||||
|
)
|
||||||
|
if (!ok) continue
|
||||||
|
pending.delete(id)
|
||||||
|
void Bus.publish(Event.Replied, {
|
||||||
|
sessionID: item.info.sessionID,
|
||||||
|
requestID: item.info.id,
|
||||||
|
reply: "always",
|
||||||
|
})
|
||||||
|
yield* Deferred.succeed(item.deferred, undefined)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = Effect.fn("Permission.list")(function* () {
|
||||||
|
return Array.from(pending.values(), (item) => item.info)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ ask, reply, list })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
function expand(pattern: string): string {
|
function expand(pattern: string): string {
|
||||||
if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
|
if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
|
||||||
if (pattern === "~") return os.homedir()
|
if (pattern === "~") return os.homedir()
|
||||||
@@ -14,32 +254,11 @@ export namespace PermissionNext {
|
|||||||
return pattern
|
return pattern
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Action = S.Action
|
|
||||||
export type Action = S.Action
|
|
||||||
export const Rule = S.Rule
|
|
||||||
export type Rule = S.Rule
|
|
||||||
export const Ruleset = S.Ruleset
|
|
||||||
export type Ruleset = S.Ruleset
|
|
||||||
export const Request = S.Request
|
|
||||||
export type Request = S.Request
|
|
||||||
export const Reply = S.Reply
|
|
||||||
export type Reply = S.Reply
|
|
||||||
export const Approval = S.Approval
|
|
||||||
export const Event = S.Event
|
|
||||||
export const Service = S.Service
|
|
||||||
export const RejectedError = S.RejectedError
|
|
||||||
export const CorrectedError = S.CorrectedError
|
|
||||||
export const DeniedError = S.DeniedError
|
|
||||||
|
|
||||||
export function fromConfig(permission: Config.Permission) {
|
export function fromConfig(permission: Config.Permission) {
|
||||||
const ruleset: Ruleset = []
|
const ruleset: Ruleset = []
|
||||||
for (const [key, value] of Object.entries(permission)) {
|
for (const [key, value] of Object.entries(permission)) {
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
ruleset.push({
|
ruleset.push({ permission: key, action: value, pattern: "*" })
|
||||||
permission: key,
|
|
||||||
action: value,
|
|
||||||
pattern: "*",
|
|
||||||
})
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
ruleset.push(
|
ruleset.push(
|
||||||
@@ -53,18 +272,12 @@ export namespace PermissionNext {
|
|||||||
return rulesets.flat()
|
return rulesets.flat()
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ask = fn(S.AskInput, async (input) => runPromiseInstance(S.Service.use((service) => service.ask(input))))
|
export const ask = fn(AskInput, async (input) => runPromiseInstance(Service.use((svc) => svc.ask(input))))
|
||||||
|
|
||||||
export const reply = fn(S.ReplyInput, async (input) =>
|
export const reply = fn(ReplyInput, async (input) => runPromiseInstance(Service.use((svc) => svc.reply(input))))
|
||||||
runPromiseInstance(S.Service.use((service) => service.reply(input))),
|
|
||||||
)
|
|
||||||
|
|
||||||
export async function list() {
|
export async function list() {
|
||||||
return runPromiseInstance(S.Service.use((service) => service.list()))
|
return runPromiseInstance(Service.use((svc) => svc.list()))
|
||||||
}
|
|
||||||
|
|
||||||
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
|
||||||
return S.evaluate(permission, pattern, ...rulesets)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
|
const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
|
||||||
|
|||||||
@@ -1,244 +0,0 @@
|
|||||||
import { Bus } from "@/bus"
|
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
|
||||||
import { InstanceContext } from "@/effect/instance-context"
|
|
||||||
import { ProjectID } from "@/project/schema"
|
|
||||||
import { MessageID, SessionID } from "@/session/schema"
|
|
||||||
import { PermissionTable } from "@/session/session.sql"
|
|
||||||
import { Database, eq } from "@/storage/db"
|
|
||||||
import { Log } from "@/util/log"
|
|
||||||
import { Wildcard } from "@/util/wildcard"
|
|
||||||
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
|
||||||
import z from "zod"
|
|
||||||
import { PermissionID } from "./schema"
|
|
||||||
|
|
||||||
export namespace PermissionEffect {
|
|
||||||
const log = Log.create({ service: "permission" })
|
|
||||||
|
|
||||||
export const Action = z.enum(["allow", "deny", "ask"]).meta({
|
|
||||||
ref: "PermissionAction",
|
|
||||||
})
|
|
||||||
export type Action = z.infer<typeof Action>
|
|
||||||
|
|
||||||
export const Rule = z
|
|
||||||
.object({
|
|
||||||
permission: z.string(),
|
|
||||||
pattern: z.string(),
|
|
||||||
action: Action,
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "PermissionRule",
|
|
||||||
})
|
|
||||||
export type Rule = z.infer<typeof Rule>
|
|
||||||
|
|
||||||
export const Ruleset = Rule.array().meta({
|
|
||||||
ref: "PermissionRuleset",
|
|
||||||
})
|
|
||||||
export type Ruleset = z.infer<typeof Ruleset>
|
|
||||||
|
|
||||||
export const Request = z
|
|
||||||
.object({
|
|
||||||
id: PermissionID.zod,
|
|
||||||
sessionID: SessionID.zod,
|
|
||||||
permission: z.string(),
|
|
||||||
patterns: z.string().array(),
|
|
||||||
metadata: z.record(z.string(), z.any()),
|
|
||||||
always: z.string().array(),
|
|
||||||
tool: z
|
|
||||||
.object({
|
|
||||||
messageID: MessageID.zod,
|
|
||||||
callID: z.string(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "PermissionRequest",
|
|
||||||
})
|
|
||||||
export type Request = z.infer<typeof Request>
|
|
||||||
|
|
||||||
export const Reply = z.enum(["once", "always", "reject"])
|
|
||||||
export type Reply = z.infer<typeof Reply>
|
|
||||||
|
|
||||||
export const Approval = z.object({
|
|
||||||
projectID: ProjectID.zod,
|
|
||||||
patterns: z.string().array(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const Event = {
|
|
||||||
Asked: BusEvent.define("permission.asked", Request),
|
|
||||||
Replied: BusEvent.define(
|
|
||||||
"permission.replied",
|
|
||||||
z.object({
|
|
||||||
sessionID: SessionID.zod,
|
|
||||||
requestID: PermissionID.zod,
|
|
||||||
reply: Reply,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
|
|
||||||
override get message() {
|
|
||||||
return "The user rejected permission to use this specific tool call."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
|
|
||||||
feedback: Schema.String,
|
|
||||||
}) {
|
|
||||||
override get message() {
|
|
||||||
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
|
|
||||||
ruleset: Schema.Any,
|
|
||||||
}) {
|
|
||||||
override get message() {
|
|
||||||
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Error = DeniedError | RejectedError | CorrectedError
|
|
||||||
|
|
||||||
export const AskInput = Request.partial({ id: true }).extend({
|
|
||||||
ruleset: Ruleset,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const ReplyInput = z.object({
|
|
||||||
requestID: PermissionID.zod,
|
|
||||||
reply: Reply,
|
|
||||||
message: z.string().optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export interface Api {
|
|
||||||
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error>
|
|
||||||
readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
|
|
||||||
readonly list: () => Effect.Effect<Request[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PendingEntry {
|
|
||||||
info: Request
|
|
||||||
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
|
||||||
const rules = rulesets.flat()
|
|
||||||
log.info("evaluate", { permission, pattern, ruleset: rules })
|
|
||||||
const match = rules.findLast(
|
|
||||||
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
|
|
||||||
)
|
|
||||||
return match ?? { action: "ask", permission, pattern: "*" }
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Api>()("@opencode/PermissionNext") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const { project } = yield* InstanceContext
|
|
||||||
const row = Database.use((db) =>
|
|
||||||
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, project.id)).get(),
|
|
||||||
)
|
|
||||||
const pending = new Map<PermissionID, PendingEntry>()
|
|
||||||
const approved: Ruleset = row?.data ?? []
|
|
||||||
|
|
||||||
const ask = Effect.fn("PermissionService.ask")(function* (input: z.infer<typeof AskInput>) {
|
|
||||||
const { ruleset, ...request } = input
|
|
||||||
let needsAsk = false
|
|
||||||
|
|
||||||
for (const pattern of request.patterns) {
|
|
||||||
const rule = evaluate(request.permission, pattern, ruleset, approved)
|
|
||||||
log.info("evaluated", { permission: request.permission, pattern, action: rule })
|
|
||||||
if (rule.action === "deny") {
|
|
||||||
return yield* new DeniedError({
|
|
||||||
ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (rule.action === "allow") continue
|
|
||||||
needsAsk = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!needsAsk) return
|
|
||||||
|
|
||||||
const id = request.id ?? PermissionID.ascending()
|
|
||||||
const info: Request = {
|
|
||||||
id,
|
|
||||||
...request,
|
|
||||||
}
|
|
||||||
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
|
|
||||||
|
|
||||||
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
|
|
||||||
pending.set(id, { info, deferred })
|
|
||||||
void Bus.publish(Event.Asked, info)
|
|
||||||
return yield* Effect.ensuring(
|
|
||||||
Deferred.await(deferred),
|
|
||||||
Effect.sync(() => {
|
|
||||||
pending.delete(id)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const reply = Effect.fn("PermissionService.reply")(function* (input: z.infer<typeof ReplyInput>) {
|
|
||||||
const existing = pending.get(input.requestID)
|
|
||||||
if (!existing) return
|
|
||||||
|
|
||||||
pending.delete(input.requestID)
|
|
||||||
void Bus.publish(Event.Replied, {
|
|
||||||
sessionID: existing.info.sessionID,
|
|
||||||
requestID: existing.info.id,
|
|
||||||
reply: input.reply,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (input.reply === "reject") {
|
|
||||||
yield* Deferred.fail(
|
|
||||||
existing.deferred,
|
|
||||||
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const [id, item] of pending.entries()) {
|
|
||||||
if (item.info.sessionID !== existing.info.sessionID) continue
|
|
||||||
pending.delete(id)
|
|
||||||
void Bus.publish(Event.Replied, {
|
|
||||||
sessionID: item.info.sessionID,
|
|
||||||
requestID: item.info.id,
|
|
||||||
reply: "reject",
|
|
||||||
})
|
|
||||||
yield* Deferred.fail(item.deferred, new RejectedError())
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* Deferred.succeed(existing.deferred, undefined)
|
|
||||||
if (input.reply === "once") return
|
|
||||||
|
|
||||||
for (const pattern of existing.info.always) {
|
|
||||||
approved.push({
|
|
||||||
permission: existing.info.permission,
|
|
||||||
pattern,
|
|
||||||
action: "allow",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [id, item] of pending.entries()) {
|
|
||||||
if (item.info.sessionID !== existing.info.sessionID) continue
|
|
||||||
const ok = item.info.patterns.every(
|
|
||||||
(pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",
|
|
||||||
)
|
|
||||||
if (!ok) continue
|
|
||||||
pending.delete(id)
|
|
||||||
void Bus.publish(Event.Replied, {
|
|
||||||
sessionID: item.info.sessionID,
|
|
||||||
requestID: item.info.id,
|
|
||||||
reply: "always",
|
|
||||||
})
|
|
||||||
yield* Deferred.succeed(item.deferred, undefined)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const list = Effect.fn("PermissionService.list")(function* () {
|
|
||||||
return Array.from(pending.values(), (item) => item.info)
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ ask, reply, list })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,30 +1,23 @@
|
|||||||
import { Plugin } from "../plugin"
|
import { Plugin } from "../plugin"
|
||||||
import { Format } from "../format"
|
|
||||||
import { LSP } from "../lsp"
|
import { LSP } from "../lsp"
|
||||||
import { FileWatcherService } from "../file/watcher"
|
|
||||||
import { File } from "../file"
|
import { File } from "../file"
|
||||||
import { Project } from "./project"
|
import { Project } from "./project"
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
import { Command } from "../command"
|
import { Command } from "../command"
|
||||||
import { Instance } from "./instance"
|
import { Instance } from "./instance"
|
||||||
import { VcsService } from "./vcs"
|
|
||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
import { ShareNext } from "@/share/share-next"
|
import { ShareNext } from "@/share/share-next"
|
||||||
import { runPromiseInstance } from "@/effect/runtime"
|
|
||||||
|
|
||||||
export async function InstanceBootstrap() {
|
export async function InstanceBootstrap() {
|
||||||
Log.Default.info("bootstrapping", { directory: Instance.directory })
|
Log.Default.info("bootstrapping", { directory: Instance.directory })
|
||||||
await Plugin.init()
|
await Plugin.init()
|
||||||
ShareNext.init()
|
ShareNext.init()
|
||||||
await Format.init()
|
|
||||||
await LSP.init()
|
await LSP.init()
|
||||||
await runPromiseInstance(FileWatcherService.use((service) => service.init()))
|
|
||||||
File.init()
|
File.init()
|
||||||
await runPromiseInstance(VcsService.use((s) => s.init()))
|
|
||||||
|
|
||||||
Bus.subscribe(Command.Event.Executed, async (payload) => {
|
Bus.subscribe(Command.Event.Executed, async (payload) => {
|
||||||
if (payload.properties.name === Command.Default.INIT) {
|
if (payload.properties.name === Command.Default.INIT) {
|
||||||
await Project.setInitialized(Instance.project.id)
|
Project.setInitialized(Instance.project.id)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import z from "zod"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { Log } from "@/util/log"
|
|
||||||
import { Instance } from "./instance"
|
|
||||||
import { InstanceContext } from "@/effect/instance-context"
|
import { InstanceContext } from "@/effect/instance-context"
|
||||||
import { FileWatcher } from "@/file/watcher"
|
import { FileWatcher } from "@/file/watcher"
|
||||||
|
import { Log } from "@/util/log"
|
||||||
import { git } from "@/util/git"
|
import { git } from "@/util/git"
|
||||||
import { Effect, Layer, ServiceMap } from "effect"
|
import { Instance } from "./instance"
|
||||||
|
import z from "zod"
|
||||||
const log = Log.create({ service: "vcs" })
|
|
||||||
|
|
||||||
export namespace Vcs {
|
export namespace Vcs {
|
||||||
|
const log = Log.create({ service: "vcs" })
|
||||||
|
|
||||||
export const Event = {
|
export const Event = {
|
||||||
BranchUpdated: BusEvent.define(
|
BranchUpdated: BusEvent.define(
|
||||||
"vcs.branch.updated",
|
"vcs.branch.updated",
|
||||||
@@ -28,24 +28,21 @@ export namespace Vcs {
|
|||||||
ref: "VcsInfo",
|
ref: "VcsInfo",
|
||||||
})
|
})
|
||||||
export type Info = z.infer<typeof Info>
|
export type Info = z.infer<typeof Info>
|
||||||
}
|
|
||||||
|
|
||||||
export namespace VcsService {
|
export interface Interface {
|
||||||
export interface Service {
|
|
||||||
readonly init: () => Effect.Effect<void>
|
|
||||||
readonly branch: () => Effect.Effect<string | undefined>
|
readonly branch: () => Effect.Effect<string | undefined>
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export class VcsService extends ServiceMap.Service<VcsService, VcsService.Service>()("@opencode/Vcs") {
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
|
||||||
static readonly layer = Layer.effect(
|
|
||||||
VcsService,
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const instance = yield* InstanceContext
|
const instance = yield* InstanceContext
|
||||||
let current: string | undefined
|
let currentBranch: string | undefined
|
||||||
|
|
||||||
if (instance.project.vcs === "git") {
|
if (instance.project.vcs === "git") {
|
||||||
const currentBranch = async () => {
|
const getCurrentBranch = async () => {
|
||||||
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
|
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
|
||||||
cwd: instance.project.worktree,
|
cwd: instance.project.worktree,
|
||||||
})
|
})
|
||||||
@@ -54,29 +51,31 @@ export class VcsService extends ServiceMap.Service<VcsService, VcsService.Servic
|
|||||||
return text || undefined
|
return text || undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
current = yield* Effect.promise(() => currentBranch())
|
currentBranch = yield* Effect.promise(() => getCurrentBranch())
|
||||||
log.info("initialized", { branch: current })
|
log.info("initialized", { branch: currentBranch })
|
||||||
|
|
||||||
const unsubscribe = Bus.subscribe(
|
yield* Effect.acquireRelease(
|
||||||
FileWatcher.Event.Updated,
|
Effect.sync(() =>
|
||||||
Instance.bind(async (evt) => {
|
Bus.subscribe(
|
||||||
if (!evt.properties.file.endsWith("HEAD")) return
|
FileWatcher.Event.Updated,
|
||||||
const next = await currentBranch()
|
Instance.bind(async (evt) => {
|
||||||
if (next !== current) {
|
if (!evt.properties.file.endsWith("HEAD")) return
|
||||||
log.info("branch changed", { from: current, to: next })
|
const next = await getCurrentBranch()
|
||||||
current = next
|
if (next !== currentBranch) {
|
||||||
Bus.publish(Vcs.Event.BranchUpdated, { branch: next })
|
log.info("branch changed", { from: currentBranch, to: next })
|
||||||
}
|
currentBranch = next
|
||||||
}),
|
Bus.publish(Event.BranchUpdated, { branch: next })
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(unsubscribe) => Effect.sync(unsubscribe),
|
||||||
)
|
)
|
||||||
|
|
||||||
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return VcsService.of({
|
return Service.of({
|
||||||
init: Effect.fn("VcsService.init")(function* () {}),
|
branch: Effect.fn("Vcs.branch")(function* () {
|
||||||
branch: Effect.fn("VcsService.branch")(function* () {
|
return currentBranch
|
||||||
return current
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,230 +0,0 @@
|
|||||||
import type { AuthOuathResult } from "@opencode-ai/plugin"
|
|
||||||
import { NamedError } from "@opencode-ai/util/error"
|
|
||||||
import * as Auth from "@/auth/service"
|
|
||||||
import { ProviderID } from "./schema"
|
|
||||||
import { Effect, Layer, Record, ServiceMap, Struct } from "effect"
|
|
||||||
import { filter, fromEntries, map, pipe } from "remeda"
|
|
||||||
import z from "zod"
|
|
||||||
|
|
||||||
export const Method = z
|
|
||||||
.object({
|
|
||||||
type: z.union([z.literal("oauth"), z.literal("api")]),
|
|
||||||
label: z.string(),
|
|
||||||
prompts: z
|
|
||||||
.array(
|
|
||||||
z.union([
|
|
||||||
z.object({
|
|
||||||
type: z.literal("text"),
|
|
||||||
key: z.string(),
|
|
||||||
message: z.string(),
|
|
||||||
placeholder: z.string().optional(),
|
|
||||||
when: z
|
|
||||||
.object({
|
|
||||||
key: z.string(),
|
|
||||||
op: z.union([z.literal("eq"), z.literal("neq")]),
|
|
||||||
value: z.string(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
}),
|
|
||||||
z.object({
|
|
||||||
type: z.literal("select"),
|
|
||||||
key: z.string(),
|
|
||||||
message: z.string(),
|
|
||||||
options: z.array(
|
|
||||||
z.object({
|
|
||||||
label: z.string(),
|
|
||||||
value: z.string(),
|
|
||||||
hint: z.string().optional(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
when: z
|
|
||||||
.object({
|
|
||||||
key: z.string(),
|
|
||||||
op: z.union([z.literal("eq"), z.literal("neq")]),
|
|
||||||
value: z.string(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "ProviderAuthMethod",
|
|
||||||
})
|
|
||||||
export type Method = z.infer<typeof Method>
|
|
||||||
|
|
||||||
export const Authorization = z
|
|
||||||
.object({
|
|
||||||
url: z.string(),
|
|
||||||
method: z.union([z.literal("auto"), z.literal("code")]),
|
|
||||||
instructions: z.string(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "ProviderAuthAuthorization",
|
|
||||||
})
|
|
||||||
export type Authorization = z.infer<typeof Authorization>
|
|
||||||
|
|
||||||
export const OauthMissing = NamedError.create(
|
|
||||||
"ProviderAuthOauthMissing",
|
|
||||||
z.object({
|
|
||||||
providerID: ProviderID.zod,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const OauthCodeMissing = NamedError.create(
|
|
||||||
"ProviderAuthOauthCodeMissing",
|
|
||||||
z.object({
|
|
||||||
providerID: ProviderID.zod,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
|
|
||||||
|
|
||||||
export const ValidationFailed = NamedError.create(
|
|
||||||
"ProviderAuthValidationFailed",
|
|
||||||
z.object({
|
|
||||||
field: z.string(),
|
|
||||||
message: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ProviderAuthError =
|
|
||||||
| Auth.AuthServiceError
|
|
||||||
| InstanceType<typeof OauthMissing>
|
|
||||||
| InstanceType<typeof OauthCodeMissing>
|
|
||||||
| InstanceType<typeof OauthCallbackFailed>
|
|
||||||
| InstanceType<typeof ValidationFailed>
|
|
||||||
|
|
||||||
export namespace ProviderAuthService {
|
|
||||||
export interface Service {
|
|
||||||
readonly methods: () => Effect.Effect<Record<string, Method[]>>
|
|
||||||
readonly authorize: (input: {
|
|
||||||
providerID: ProviderID
|
|
||||||
method: number
|
|
||||||
inputs?: Record<string, string>
|
|
||||||
}) => Effect.Effect<Authorization | undefined, ProviderAuthError>
|
|
||||||
readonly callback: (input: {
|
|
||||||
providerID: ProviderID
|
|
||||||
method: number
|
|
||||||
code?: string
|
|
||||||
}) => Effect.Effect<void, ProviderAuthError>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService, ProviderAuthService.Service>()(
|
|
||||||
"@opencode/ProviderAuth",
|
|
||||||
) {
|
|
||||||
static readonly layer = Layer.effect(
|
|
||||||
ProviderAuthService,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const auth = yield* Auth.AuthService
|
|
||||||
const hooks = yield* Effect.promise(async () => {
|
|
||||||
const mod = await import("../plugin")
|
|
||||||
return pipe(
|
|
||||||
await mod.Plugin.list(),
|
|
||||||
filter((x) => x.auth?.provider !== undefined),
|
|
||||||
map((x) => [x.auth!.provider, x.auth!] as const),
|
|
||||||
fromEntries(),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
const pending = new Map<ProviderID, AuthOuathResult>()
|
|
||||||
|
|
||||||
const methods = Effect.fn("ProviderAuthService.methods")(function* () {
|
|
||||||
return Record.map(hooks, (item) =>
|
|
||||||
item.methods.map(
|
|
||||||
(method): Method => ({
|
|
||||||
type: method.type,
|
|
||||||
label: method.label,
|
|
||||||
prompts: method.prompts?.map((prompt) => {
|
|
||||||
if (prompt.type === "select") {
|
|
||||||
return {
|
|
||||||
type: "select" as const,
|
|
||||||
key: prompt.key,
|
|
||||||
message: prompt.message,
|
|
||||||
options: prompt.options,
|
|
||||||
when: prompt.when,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
type: "text" as const,
|
|
||||||
key: prompt.key,
|
|
||||||
message: prompt.message,
|
|
||||||
placeholder: prompt.placeholder,
|
|
||||||
when: prompt.when,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const authorize = Effect.fn("ProviderAuthService.authorize")(function* (input: {
|
|
||||||
providerID: ProviderID
|
|
||||||
method: number
|
|
||||||
inputs?: Record<string, string>
|
|
||||||
}) {
|
|
||||||
const method = hooks[input.providerID].methods[input.method]
|
|
||||||
if (method.type !== "oauth") return
|
|
||||||
|
|
||||||
if (method.prompts && input.inputs) {
|
|
||||||
for (const prompt of method.prompts) {
|
|
||||||
if (prompt.type === "text" && prompt.validate && input.inputs[prompt.key] !== undefined) {
|
|
||||||
const error = prompt.validate(input.inputs[prompt.key])
|
|
||||||
if (error) return yield* Effect.fail(new ValidationFailed({ field: prompt.key, message: error }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = yield* Effect.promise(() => method.authorize(input.inputs))
|
|
||||||
pending.set(input.providerID, result)
|
|
||||||
return {
|
|
||||||
url: result.url,
|
|
||||||
method: result.method,
|
|
||||||
instructions: result.instructions,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const callback = Effect.fn("ProviderAuthService.callback")(function* (input: {
|
|
||||||
providerID: ProviderID
|
|
||||||
method: number
|
|
||||||
code?: string
|
|
||||||
}) {
|
|
||||||
const match = pending.get(input.providerID)
|
|
||||||
if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
|
|
||||||
if (match.method === "code" && !input.code)
|
|
||||||
return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
|
|
||||||
|
|
||||||
const result = yield* Effect.promise(() =>
|
|
||||||
match.method === "code" ? match.callback(input.code!) : match.callback(),
|
|
||||||
)
|
|
||||||
if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({}))
|
|
||||||
|
|
||||||
if ("key" in result) {
|
|
||||||
yield* auth.set(input.providerID, {
|
|
||||||
type: "api",
|
|
||||||
key: result.key,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if ("refresh" in result) {
|
|
||||||
yield* auth.set(input.providerID, {
|
|
||||||
type: "oauth",
|
|
||||||
access: result.access,
|
|
||||||
refresh: result.refresh,
|
|
||||||
expires: result.expires,
|
|
||||||
...(result.accountId ? { accountId: result.accountId } : {}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return ProviderAuthService.of({
|
|
||||||
methods,
|
|
||||||
authorize,
|
|
||||||
callback,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
static readonly defaultLayer = ProviderAuthService.layer.pipe(Layer.provide(Auth.AuthService.defaultLayer))
|
|
||||||
}
|
|
||||||
@@ -1,20 +1,223 @@
|
|||||||
import z from "zod"
|
import type { AuthOuathResult } from "@opencode-ai/plugin"
|
||||||
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
|
import * as Auth from "@/auth/effect"
|
||||||
import { runPromiseInstance } from "@/effect/runtime"
|
import { runPromiseInstance } from "@/effect/runtime"
|
||||||
import { fn } from "@/util/fn"
|
import { fn } from "@/util/fn"
|
||||||
import * as S from "./auth-service"
|
|
||||||
import { ProviderID } from "./schema"
|
import { ProviderID } from "./schema"
|
||||||
|
import { Array as Arr, Effect, Layer, Record, Result, ServiceMap, Struct } from "effect"
|
||||||
|
import z from "zod"
|
||||||
|
|
||||||
export namespace ProviderAuth {
|
export namespace ProviderAuth {
|
||||||
export const Method = S.Method
|
export const Method = z
|
||||||
export type Method = S.Method
|
.object({
|
||||||
|
type: z.union([z.literal("oauth"), z.literal("api")]),
|
||||||
|
label: z.string(),
|
||||||
|
prompts: z
|
||||||
|
.array(
|
||||||
|
z.union([
|
||||||
|
z.object({
|
||||||
|
type: z.literal("text"),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
placeholder: z.string().optional(),
|
||||||
|
when: z
|
||||||
|
.object({
|
||||||
|
key: z.string(),
|
||||||
|
op: z.union([z.literal("eq"), z.literal("neq")]),
|
||||||
|
value: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal("select"),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
options: z.array(
|
||||||
|
z.object({
|
||||||
|
label: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
hint: z.string().optional(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
when: z
|
||||||
|
.object({
|
||||||
|
key: z.string(),
|
||||||
|
op: z.union([z.literal("eq"), z.literal("neq")]),
|
||||||
|
value: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "ProviderAuthMethod",
|
||||||
|
})
|
||||||
|
export type Method = z.infer<typeof Method>
|
||||||
|
|
||||||
export async function methods() {
|
export const Authorization = z
|
||||||
return runPromiseInstance(S.ProviderAuthService.use((service) => service.methods()))
|
.object({
|
||||||
|
url: z.string(),
|
||||||
|
method: z.union([z.literal("auto"), z.literal("code")]),
|
||||||
|
instructions: z.string(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "ProviderAuthAuthorization",
|
||||||
|
})
|
||||||
|
export type Authorization = z.infer<typeof Authorization>
|
||||||
|
|
||||||
|
export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", z.object({ providerID: ProviderID.zod }))
|
||||||
|
|
||||||
|
export const OauthCodeMissing = NamedError.create(
|
||||||
|
"ProviderAuthOauthCodeMissing",
|
||||||
|
z.object({ providerID: ProviderID.zod }),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
|
||||||
|
|
||||||
|
export const ValidationFailed = NamedError.create(
|
||||||
|
"ProviderAuthValidationFailed",
|
||||||
|
z.object({
|
||||||
|
field: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export type Error =
|
||||||
|
| Auth.AuthError
|
||||||
|
| InstanceType<typeof OauthMissing>
|
||||||
|
| InstanceType<typeof OauthCodeMissing>
|
||||||
|
| InstanceType<typeof OauthCallbackFailed>
|
||||||
|
| InstanceType<typeof ValidationFailed>
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly methods: () => Effect.Effect<Record<ProviderID, Method[]>>
|
||||||
|
readonly authorize: (input: {
|
||||||
|
providerID: ProviderID
|
||||||
|
method: number
|
||||||
|
inputs?: Record<string, string>
|
||||||
|
}) => Effect.Effect<Authorization | undefined, Error>
|
||||||
|
readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect<void, Error>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Authorization = S.Authorization
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
|
||||||
export type Authorization = S.Authorization
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const auth = yield* Auth.AuthEffect.Service
|
||||||
|
const hooks = yield* Effect.promise(async () => {
|
||||||
|
const mod = await import("../plugin")
|
||||||
|
const plugins = await mod.Plugin.list()
|
||||||
|
return Record.fromEntries(
|
||||||
|
Arr.filterMap(plugins, (x) =>
|
||||||
|
x.auth?.provider !== undefined
|
||||||
|
? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const)
|
||||||
|
: Result.failVoid,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const pending = new Map<ProviderID, AuthOuathResult>()
|
||||||
|
|
||||||
|
const methods = Effect.fn("ProviderAuth.methods")(function* () {
|
||||||
|
return Record.map(hooks, (item) =>
|
||||||
|
item.methods.map(
|
||||||
|
(method): Method => ({
|
||||||
|
type: method.type,
|
||||||
|
label: method.label,
|
||||||
|
prompts: method.prompts?.map((prompt) => {
|
||||||
|
if (prompt.type === "select") {
|
||||||
|
return {
|
||||||
|
type: "select" as const,
|
||||||
|
key: prompt.key,
|
||||||
|
message: prompt.message,
|
||||||
|
options: prompt.options,
|
||||||
|
when: prompt.when,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "text" as const,
|
||||||
|
key: prompt.key,
|
||||||
|
message: prompt.message,
|
||||||
|
placeholder: prompt.placeholder,
|
||||||
|
when: prompt.when,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const authorize = Effect.fn("ProviderAuth.authorize")(function* (input: {
|
||||||
|
providerID: ProviderID
|
||||||
|
method: number
|
||||||
|
inputs?: Record<string, string>
|
||||||
|
}) {
|
||||||
|
const method = hooks[input.providerID].methods[input.method]
|
||||||
|
if (method.type !== "oauth") return
|
||||||
|
|
||||||
|
if (method.prompts && input.inputs) {
|
||||||
|
for (const prompt of method.prompts) {
|
||||||
|
if (prompt.type === "text" && prompt.validate && input.inputs[prompt.key] !== undefined) {
|
||||||
|
const error = prompt.validate(input.inputs[prompt.key])
|
||||||
|
if (error) return yield* Effect.fail(new ValidationFailed({ field: prompt.key, message: error }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = yield* Effect.promise(() => method.authorize(input.inputs))
|
||||||
|
pending.set(input.providerID, result)
|
||||||
|
return {
|
||||||
|
url: result.url,
|
||||||
|
method: result.method,
|
||||||
|
instructions: result.instructions,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const callback = Effect.fn("ProviderAuth.callback")(function* (input: {
|
||||||
|
providerID: ProviderID
|
||||||
|
method: number
|
||||||
|
code?: string
|
||||||
|
}) {
|
||||||
|
const match = pending.get(input.providerID)
|
||||||
|
if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
|
||||||
|
if (match.method === "code" && !input.code) {
|
||||||
|
return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = yield* Effect.promise(() =>
|
||||||
|
match.method === "code" ? match.callback(input.code!) : match.callback(),
|
||||||
|
)
|
||||||
|
if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({}))
|
||||||
|
|
||||||
|
if ("key" in result) {
|
||||||
|
yield* auth.set(input.providerID, {
|
||||||
|
type: "api",
|
||||||
|
key: result.key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("refresh" in result) {
|
||||||
|
yield* auth.set(input.providerID, {
|
||||||
|
type: "oauth",
|
||||||
|
access: result.access,
|
||||||
|
refresh: result.refresh,
|
||||||
|
expires: result.expires,
|
||||||
|
...(result.accountId ? { accountId: result.accountId } : {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ methods, authorize, callback })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(Layer.provide(Auth.AuthEffect.layer))
|
||||||
|
|
||||||
|
export async function methods() {
|
||||||
|
return runPromiseInstance(Service.use((svc) => svc.methods()))
|
||||||
|
}
|
||||||
|
|
||||||
export const authorize = fn(
|
export const authorize = fn(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -22,8 +225,7 @@ export namespace ProviderAuth {
|
|||||||
method: z.number(),
|
method: z.number(),
|
||||||
inputs: z.record(z.string(), z.string()).optional(),
|
inputs: z.record(z.string(), z.string()).optional(),
|
||||||
}),
|
}),
|
||||||
async (input): Promise<Authorization | undefined> =>
|
async (input): Promise<Authorization | undefined> => runPromiseInstance(Service.use((svc) => svc.authorize(input))),
|
||||||
runPromiseInstance(S.ProviderAuthService.use((service) => service.authorize(input))),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export const callback = fn(
|
export const callback = fn(
|
||||||
@@ -32,11 +234,6 @@ export namespace ProviderAuth {
|
|||||||
method: z.number(),
|
method: z.number(),
|
||||||
code: z.string().optional(),
|
code: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
async (input) => runPromiseInstance(S.ProviderAuthService.use((service) => service.callback(input))),
|
async (input) => runPromiseInstance(Service.use((svc) => svc.callback(input))),
|
||||||
)
|
)
|
||||||
|
|
||||||
export import OauthMissing = S.OauthMissing
|
|
||||||
export import OauthCodeMissing = S.OauthCodeMissing
|
|
||||||
export import OauthCallbackFailed = S.OauthCallbackFailed
|
|
||||||
export import ValidationFailed = S.ValidationFailed
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,193 @@
|
|||||||
|
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
||||||
import { runPromiseInstance } from "@/effect/runtime"
|
import { runPromiseInstance } from "@/effect/runtime"
|
||||||
import * as S from "./service"
|
import { Bus } from "@/bus"
|
||||||
import type { QuestionID } from "./schema"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import type { SessionID, MessageID } from "@/session/schema"
|
import { SessionID, MessageID } from "@/session/schema"
|
||||||
|
import { Log } from "@/util/log"
|
||||||
|
import z from "zod"
|
||||||
|
import { QuestionID } from "./schema"
|
||||||
|
|
||||||
|
const log = Log.create({ service: "question" })
|
||||||
|
|
||||||
export namespace Question {
|
export namespace Question {
|
||||||
export const Option = S.Option
|
// Schemas
|
||||||
export type Option = S.Option
|
|
||||||
export const Info = S.Info
|
export const Option = z
|
||||||
export type Info = S.Info
|
.object({
|
||||||
export const Request = S.Request
|
label: z.string().describe("Display text (1-5 words, concise)"),
|
||||||
export type Request = S.Request
|
description: z.string().describe("Explanation of choice"),
|
||||||
export const Answer = S.Answer
|
})
|
||||||
export type Answer = S.Answer
|
.meta({ ref: "QuestionOption" })
|
||||||
export const Reply = S.Reply
|
export type Option = z.infer<typeof Option>
|
||||||
export type Reply = S.Reply
|
|
||||||
export const Event = S.Event
|
export const Info = z
|
||||||
export const RejectedError = S.RejectedError
|
.object({
|
||||||
|
question: z.string().describe("Complete question"),
|
||||||
|
header: z.string().describe("Very short label (max 30 chars)"),
|
||||||
|
options: z.array(Option).describe("Available choices"),
|
||||||
|
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
|
||||||
|
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
|
||||||
|
})
|
||||||
|
.meta({ ref: "QuestionInfo" })
|
||||||
|
export type Info = z.infer<typeof Info>
|
||||||
|
|
||||||
|
export const Request = z
|
||||||
|
.object({
|
||||||
|
id: QuestionID.zod,
|
||||||
|
sessionID: SessionID.zod,
|
||||||
|
questions: z.array(Info).describe("Questions to ask"),
|
||||||
|
tool: z
|
||||||
|
.object({
|
||||||
|
messageID: MessageID.zod,
|
||||||
|
callID: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.meta({ ref: "QuestionRequest" })
|
||||||
|
export type Request = z.infer<typeof Request>
|
||||||
|
|
||||||
|
export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
|
||||||
|
export type Answer = z.infer<typeof Answer>
|
||||||
|
|
||||||
|
export const Reply = z.object({
|
||||||
|
answers: z
|
||||||
|
.array(Answer)
|
||||||
|
.describe("User answers in order of questions (each answer is an array of selected labels)"),
|
||||||
|
})
|
||||||
|
export type Reply = z.infer<typeof Reply>
|
||||||
|
|
||||||
|
export const Event = {
|
||||||
|
Asked: BusEvent.define("question.asked", Request),
|
||||||
|
Replied: BusEvent.define(
|
||||||
|
"question.replied",
|
||||||
|
z.object({
|
||||||
|
sessionID: SessionID.zod,
|
||||||
|
requestID: QuestionID.zod,
|
||||||
|
answers: z.array(Answer),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Rejected: BusEvent.define(
|
||||||
|
"question.rejected",
|
||||||
|
z.object({
|
||||||
|
sessionID: SessionID.zod,
|
||||||
|
requestID: QuestionID.zod,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
|
||||||
|
override get message() {
|
||||||
|
return "The user dismissed this question"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingEntry {
|
||||||
|
info: Request
|
||||||
|
deferred: Deferred.Deferred<Answer[], RejectedError>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly ask: (input: {
|
||||||
|
sessionID: SessionID
|
||||||
|
questions: Info[]
|
||||||
|
tool?: { messageID: MessageID; callID: string }
|
||||||
|
}) => Effect.Effect<Answer[], RejectedError>
|
||||||
|
readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
|
||||||
|
readonly reject: (requestID: QuestionID) => Effect.Effect<void>
|
||||||
|
readonly list: () => Effect.Effect<Request[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Question") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const pending = new Map<QuestionID, PendingEntry>()
|
||||||
|
|
||||||
|
const ask = Effect.fn("Question.ask")(function* (input: {
|
||||||
|
sessionID: SessionID
|
||||||
|
questions: Info[]
|
||||||
|
tool?: { messageID: MessageID; callID: string }
|
||||||
|
}) {
|
||||||
|
const id = QuestionID.ascending()
|
||||||
|
log.info("asking", { id, questions: input.questions.length })
|
||||||
|
|
||||||
|
const deferred = yield* Deferred.make<Answer[], RejectedError>()
|
||||||
|
const info: Request = {
|
||||||
|
id,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
questions: input.questions,
|
||||||
|
tool: input.tool,
|
||||||
|
}
|
||||||
|
pending.set(id, { info, deferred })
|
||||||
|
Bus.publish(Event.Asked, info)
|
||||||
|
|
||||||
|
return yield* Effect.ensuring(
|
||||||
|
Deferred.await(deferred),
|
||||||
|
Effect.sync(() => {
|
||||||
|
pending.delete(id)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const reply = Effect.fn("Question.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
|
||||||
|
const existing = pending.get(input.requestID)
|
||||||
|
if (!existing) {
|
||||||
|
log.warn("reply for unknown request", { requestID: input.requestID })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pending.delete(input.requestID)
|
||||||
|
log.info("replied", { requestID: input.requestID, answers: input.answers })
|
||||||
|
Bus.publish(Event.Replied, {
|
||||||
|
sessionID: existing.info.sessionID,
|
||||||
|
requestID: existing.info.id,
|
||||||
|
answers: input.answers,
|
||||||
|
})
|
||||||
|
yield* Deferred.succeed(existing.deferred, input.answers)
|
||||||
|
})
|
||||||
|
|
||||||
|
const reject = Effect.fn("Question.reject")(function* (requestID: QuestionID) {
|
||||||
|
const existing = pending.get(requestID)
|
||||||
|
if (!existing) {
|
||||||
|
log.warn("reject for unknown request", { requestID })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pending.delete(requestID)
|
||||||
|
log.info("rejected", { requestID })
|
||||||
|
Bus.publish(Event.Rejected, {
|
||||||
|
sessionID: existing.info.sessionID,
|
||||||
|
requestID: existing.info.id,
|
||||||
|
})
|
||||||
|
yield* Deferred.fail(existing.deferred, new RejectedError())
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = Effect.fn("Question.list")(function* () {
|
||||||
|
return Array.from(pending.values(), (x) => x.info)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ ask, reply, reject, list })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
export async function ask(input: {
|
export async function ask(input: {
|
||||||
sessionID: SessionID
|
sessionID: SessionID
|
||||||
questions: Info[]
|
questions: Info[]
|
||||||
tool?: { messageID: MessageID; callID: string }
|
tool?: { messageID: MessageID; callID: string }
|
||||||
}): Promise<Answer[]> {
|
}): Promise<Answer[]> {
|
||||||
return runPromiseInstance(S.QuestionService.use((service) => service.ask(input)))
|
return runPromiseInstance(Service.use((svc) => svc.ask(input)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise<void> {
|
export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise<void> {
|
||||||
return runPromiseInstance(S.QuestionService.use((service) => service.reply(input)))
|
return runPromiseInstance(Service.use((svc) => svc.reply(input)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reject(requestID: QuestionID): Promise<void> {
|
export async function reject(requestID: QuestionID): Promise<void> {
|
||||||
return runPromiseInstance(S.QuestionService.use((service) => service.reject(requestID)))
|
return runPromiseInstance(Service.use((svc) => svc.reject(requestID)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function list(): Promise<Request[]> {
|
export async function list(): Promise<Request[]> {
|
||||||
return runPromiseInstance(S.QuestionService.use((service) => service.list()))
|
return runPromiseInstance(Service.use((svc) => svc.list()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,172 +0,0 @@
|
|||||||
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
|
||||||
import { Bus } from "@/bus"
|
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
|
||||||
import { SessionID, MessageID } from "@/session/schema"
|
|
||||||
import { Log } from "@/util/log"
|
|
||||||
import z from "zod"
|
|
||||||
import { QuestionID } from "./schema"
|
|
||||||
|
|
||||||
const log = Log.create({ service: "question" })
|
|
||||||
|
|
||||||
// --- Zod schemas (re-exported by facade) ---
|
|
||||||
|
|
||||||
export const Option = z
|
|
||||||
.object({
|
|
||||||
label: z.string().describe("Display text (1-5 words, concise)"),
|
|
||||||
description: z.string().describe("Explanation of choice"),
|
|
||||||
})
|
|
||||||
.meta({ ref: "QuestionOption" })
|
|
||||||
export type Option = z.infer<typeof Option>
|
|
||||||
|
|
||||||
export const Info = z
|
|
||||||
.object({
|
|
||||||
question: z.string().describe("Complete question"),
|
|
||||||
header: z.string().describe("Very short label (max 30 chars)"),
|
|
||||||
options: z.array(Option).describe("Available choices"),
|
|
||||||
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
|
|
||||||
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
|
|
||||||
})
|
|
||||||
.meta({ ref: "QuestionInfo" })
|
|
||||||
export type Info = z.infer<typeof Info>
|
|
||||||
|
|
||||||
export const Request = z
|
|
||||||
.object({
|
|
||||||
id: QuestionID.zod,
|
|
||||||
sessionID: SessionID.zod,
|
|
||||||
questions: z.array(Info).describe("Questions to ask"),
|
|
||||||
tool: z
|
|
||||||
.object({
|
|
||||||
messageID: MessageID.zod,
|
|
||||||
callID: z.string(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
.meta({ ref: "QuestionRequest" })
|
|
||||||
export type Request = z.infer<typeof Request>
|
|
||||||
|
|
||||||
export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
|
|
||||||
export type Answer = z.infer<typeof Answer>
|
|
||||||
|
|
||||||
export const Reply = z.object({
|
|
||||||
answers: z.array(Answer).describe("User answers in order of questions (each answer is an array of selected labels)"),
|
|
||||||
})
|
|
||||||
export type Reply = z.infer<typeof Reply>
|
|
||||||
|
|
||||||
export const Event = {
|
|
||||||
Asked: BusEvent.define("question.asked", Request),
|
|
||||||
Replied: BusEvent.define(
|
|
||||||
"question.replied",
|
|
||||||
z.object({
|
|
||||||
sessionID: SessionID.zod,
|
|
||||||
requestID: QuestionID.zod,
|
|
||||||
answers: z.array(Answer),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
Rejected: BusEvent.define(
|
|
||||||
"question.rejected",
|
|
||||||
z.object({
|
|
||||||
sessionID: SessionID.zod,
|
|
||||||
requestID: QuestionID.zod,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
|
|
||||||
override get message() {
|
|
||||||
return "The user dismissed this question"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Effect service ---
|
|
||||||
|
|
||||||
interface PendingEntry {
|
|
||||||
info: Request
|
|
||||||
deferred: Deferred.Deferred<Answer[], RejectedError>
|
|
||||||
}
|
|
||||||
|
|
||||||
export namespace QuestionService {
|
|
||||||
export interface Service {
|
|
||||||
readonly ask: (input: {
|
|
||||||
sessionID: SessionID
|
|
||||||
questions: Info[]
|
|
||||||
tool?: { messageID: MessageID; callID: string }
|
|
||||||
}) => Effect.Effect<Answer[], RejectedError>
|
|
||||||
readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
|
|
||||||
readonly reject: (requestID: QuestionID) => Effect.Effect<void>
|
|
||||||
readonly list: () => Effect.Effect<Request[]>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class QuestionService extends ServiceMap.Service<QuestionService, QuestionService.Service>()(
|
|
||||||
"@opencode/Question",
|
|
||||||
) {
|
|
||||||
static readonly layer = Layer.effect(
|
|
||||||
QuestionService,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const pending = new Map<QuestionID, PendingEntry>()
|
|
||||||
|
|
||||||
const ask = Effect.fn("QuestionService.ask")(function* (input: {
|
|
||||||
sessionID: SessionID
|
|
||||||
questions: Info[]
|
|
||||||
tool?: { messageID: MessageID; callID: string }
|
|
||||||
}) {
|
|
||||||
const id = QuestionID.ascending()
|
|
||||||
log.info("asking", { id, questions: input.questions.length })
|
|
||||||
|
|
||||||
const deferred = yield* Deferred.make<Answer[], RejectedError>()
|
|
||||||
const info: Request = {
|
|
||||||
id,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
questions: input.questions,
|
|
||||||
tool: input.tool,
|
|
||||||
}
|
|
||||||
pending.set(id, { info, deferred })
|
|
||||||
Bus.publish(Event.Asked, info)
|
|
||||||
|
|
||||||
return yield* Effect.ensuring(
|
|
||||||
Deferred.await(deferred),
|
|
||||||
Effect.sync(() => {
|
|
||||||
pending.delete(id)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const reply = Effect.fn("QuestionService.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
|
|
||||||
const existing = pending.get(input.requestID)
|
|
||||||
if (!existing) {
|
|
||||||
log.warn("reply for unknown request", { requestID: input.requestID })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pending.delete(input.requestID)
|
|
||||||
log.info("replied", { requestID: input.requestID, answers: input.answers })
|
|
||||||
Bus.publish(Event.Replied, {
|
|
||||||
sessionID: existing.info.sessionID,
|
|
||||||
requestID: existing.info.id,
|
|
||||||
answers: input.answers,
|
|
||||||
})
|
|
||||||
yield* Deferred.succeed(existing.deferred, input.answers)
|
|
||||||
})
|
|
||||||
|
|
||||||
const reject = Effect.fn("QuestionService.reject")(function* (requestID: QuestionID) {
|
|
||||||
const existing = pending.get(requestID)
|
|
||||||
if (!existing) {
|
|
||||||
log.warn("reject for unknown request", { requestID })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pending.delete(requestID)
|
|
||||||
log.info("rejected", { requestID })
|
|
||||||
Bus.publish(Event.Rejected, {
|
|
||||||
sessionID: existing.info.sessionID,
|
|
||||||
requestID: existing.info.id,
|
|
||||||
})
|
|
||||||
yield* Deferred.fail(existing.deferred, new RejectedError())
|
|
||||||
})
|
|
||||||
|
|
||||||
const list = Effect.fn("QuestionService.list")(function* () {
|
|
||||||
return Array.from(pending.values(), (x) => x.info)
|
|
||||||
})
|
|
||||||
|
|
||||||
return QuestionService.of({ ask, reply, reject, list })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,7 @@ import { LSP } from "../lsp"
|
|||||||
import { Format } from "../format"
|
import { Format } from "../format"
|
||||||
import { TuiRoutes } from "./routes/tui"
|
import { TuiRoutes } from "./routes/tui"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { Vcs, VcsService } from "../project/vcs"
|
import { Vcs } from "../project/vcs"
|
||||||
import { runPromiseInstance } from "@/effect/runtime"
|
import { runPromiseInstance } from "@/effect/runtime"
|
||||||
import { Agent } from "../agent/agent"
|
import { Agent } from "../agent/agent"
|
||||||
import { Skill } from "../skill/skill"
|
import { Skill } from "../skill/skill"
|
||||||
@@ -332,7 +332,7 @@ export namespace Server {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const branch = await runPromiseInstance(VcsService.use((s) => s.branch()))
|
const branch = await runPromiseInstance(Vcs.Service.use((s) => s.branch()))
|
||||||
return c.json({
|
return c.json({
|
||||||
branch,
|
branch,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,116 +1,117 @@
|
|||||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||||
import { Effect, FileSystem, Layer, Path, Schema, ServiceMap } from "effect"
|
import { Effect, FileSystem, Layer, Path, Schema, ServiceMap } from "effect"
|
||||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
|
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
|
||||||
|
|
||||||
class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({
|
export namespace Discovery {
|
||||||
name: Schema.String,
|
const skillConcurrency = 4
|
||||||
files: Schema.Array(Schema.String),
|
const fileConcurrency = 8
|
||||||
}) {}
|
|
||||||
|
|
||||||
class Index extends Schema.Class<Index>("Index")({
|
class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({
|
||||||
skills: Schema.Array(IndexSkill),
|
name: Schema.String,
|
||||||
}) {}
|
files: Schema.Array(Schema.String),
|
||||||
|
}) {}
|
||||||
|
|
||||||
const skillConcurrency = 4
|
class Index extends Schema.Class<Index>("Index")({
|
||||||
const fileConcurrency = 8
|
skills: Schema.Array(IndexSkill),
|
||||||
|
}) {}
|
||||||
|
|
||||||
export namespace DiscoveryService {
|
export interface Interface {
|
||||||
export interface Service {
|
|
||||||
readonly pull: (url: string) => Effect.Effect<string[]>
|
readonly pull: (url: string) => Effect.Effect<string[]>
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export class DiscoveryService extends ServiceMap.Service<DiscoveryService, DiscoveryService.Service>()(
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SkillDiscovery") {}
|
||||||
"@opencode/SkillDiscovery",
|
|
||||||
) {
|
|
||||||
static readonly layer = Layer.effect(
|
|
||||||
DiscoveryService,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const log = Log.create({ service: "skill-discovery" })
|
|
||||||
const fs = yield* FileSystem.FileSystem
|
|
||||||
const path = yield* Path.Path
|
|
||||||
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
|
|
||||||
const cache = path.join(Global.Path.cache, "skills")
|
|
||||||
|
|
||||||
const download = Effect.fn("DiscoveryService.download")(function* (url: string, dest: string) {
|
export const layer: Layer.Layer<Service, never, FileSystem.FileSystem | Path.Path | HttpClient.HttpClient> =
|
||||||
if (yield* fs.exists(dest).pipe(Effect.orDie)) return true
|
Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const log = Log.create({ service: "skill-discovery" })
|
||||||
|
const fs = yield* FileSystem.FileSystem
|
||||||
|
const path = yield* Path.Path
|
||||||
|
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
|
||||||
|
const cache = path.join(Global.Path.cache, "skills")
|
||||||
|
|
||||||
return yield* HttpClientRequest.get(url).pipe(
|
const download = Effect.fn("Discovery.download")(function* (url: string, dest: string) {
|
||||||
http.execute,
|
if (yield* fs.exists(dest).pipe(Effect.orDie)) return true
|
||||||
Effect.flatMap((res) => res.arrayBuffer),
|
|
||||||
Effect.flatMap((body) =>
|
|
||||||
fs
|
|
||||||
.makeDirectory(path.dirname(dest), { recursive: true })
|
|
||||||
.pipe(Effect.flatMap(() => fs.writeFile(dest, new Uint8Array(body)))),
|
|
||||||
),
|
|
||||||
Effect.as(true),
|
|
||||||
Effect.catch((err) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
log.error("failed to download", { url, err })
|
|
||||||
return false
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const pull: DiscoveryService.Service["pull"] = Effect.fn("DiscoveryService.pull")(function* (url: string) {
|
return yield* HttpClientRequest.get(url).pipe(
|
||||||
const base = url.endsWith("/") ? url : `${url}/`
|
http.execute,
|
||||||
const index = new URL("index.json", base).href
|
Effect.flatMap((res) => res.arrayBuffer),
|
||||||
const host = base.slice(0, -1)
|
Effect.flatMap((body) =>
|
||||||
|
fs
|
||||||
log.info("fetching index", { url: index })
|
.makeDirectory(path.dirname(dest), { recursive: true })
|
||||||
|
.pipe(Effect.flatMap(() => fs.writeFile(dest, new Uint8Array(body)))),
|
||||||
const data = yield* HttpClientRequest.get(index).pipe(
|
),
|
||||||
HttpClientRequest.acceptJson,
|
Effect.as(true),
|
||||||
http.execute,
|
Effect.catch((err) =>
|
||||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)),
|
Effect.sync(() => {
|
||||||
Effect.catch((err) =>
|
log.error("failed to download", { url, err })
|
||||||
Effect.sync(() => {
|
return false
|
||||||
log.error("failed to fetch index", { url: index, err })
|
}),
|
||||||
return null
|
),
|
||||||
}),
|
)
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!data) return []
|
|
||||||
|
|
||||||
const list = data.skills.filter((skill) => {
|
|
||||||
if (!skill.files.includes("SKILL.md")) {
|
|
||||||
log.warn("skill entry missing SKILL.md", { url: index, skill: skill.name })
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const dirs = yield* Effect.forEach(
|
const pull = Effect.fn("Discovery.pull")(function* (url: string) {
|
||||||
list,
|
const base = url.endsWith("/") ? url : `${url}/`
|
||||||
(skill) =>
|
const index = new URL("index.json", base).href
|
||||||
Effect.gen(function* () {
|
const host = base.slice(0, -1)
|
||||||
const root = path.join(cache, skill.name)
|
|
||||||
|
|
||||||
yield* Effect.forEach(
|
log.info("fetching index", { url: index })
|
||||||
skill.files,
|
|
||||||
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
|
|
||||||
{ concurrency: fileConcurrency },
|
|
||||||
)
|
|
||||||
|
|
||||||
const md = path.join(root, "SKILL.md")
|
const data = yield* HttpClientRequest.get(index).pipe(
|
||||||
return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null
|
HttpClientRequest.acceptJson,
|
||||||
}),
|
http.execute,
|
||||||
{ concurrency: skillConcurrency },
|
Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)),
|
||||||
)
|
Effect.catch((err) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
log.error("failed to fetch index", { url: index, err })
|
||||||
|
return null
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
return dirs.filter((dir): dir is string => dir !== null)
|
if (!data) return []
|
||||||
})
|
|
||||||
|
|
||||||
return DiscoveryService.of({ pull })
|
const list = data.skills.filter((skill) => {
|
||||||
}),
|
if (!skill.files.includes("SKILL.md")) {
|
||||||
)
|
log.warn("skill entry missing SKILL.md", { url: index, skill: skill.name })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
static readonly defaultLayer = DiscoveryService.layer.pipe(
|
const dirs = yield* Effect.forEach(
|
||||||
|
list,
|
||||||
|
(skill) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const root = path.join(cache, skill.name)
|
||||||
|
|
||||||
|
yield* Effect.forEach(
|
||||||
|
skill.files,
|
||||||
|
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
|
||||||
|
{
|
||||||
|
concurrency: fileConcurrency,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const md = path.join(root, "SKILL.md")
|
||||||
|
return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null
|
||||||
|
}),
|
||||||
|
{ concurrency: skillConcurrency },
|
||||||
|
)
|
||||||
|
|
||||||
|
return dirs.filter((dir): dir is string => dir !== null)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ pull })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
Layer.provide(NodeFileSystem.layer),
|
Layer.provide(NodeFileSystem.layer),
|
||||||
Layer.provide(NodePath.layer),
|
Layer.provide(NodePath.layer),
|
||||||
|
|||||||
@@ -1,34 +1,30 @@
|
|||||||
import z from "zod"
|
|
||||||
import path from "path"
|
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import { Config } from "../config/config"
|
import path from "path"
|
||||||
import { Instance } from "../project/instance"
|
|
||||||
import { NamedError } from "@opencode-ai/util/error"
|
|
||||||
import { ConfigMarkdown } from "../config/markdown"
|
|
||||||
import { Log } from "../util/log"
|
|
||||||
import { Global } from "@/global"
|
|
||||||
import { Filesystem } from "@/util/filesystem"
|
|
||||||
import { Flag } from "@/flag/flag"
|
|
||||||
import { Bus } from "@/bus"
|
|
||||||
import { DiscoveryService } from "./discovery"
|
|
||||||
import { Glob } from "../util/glob"
|
|
||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import type { Agent } from "@/agent/agent"
|
import z from "zod"
|
||||||
import { PermissionNext } from "@/permission"
|
|
||||||
import { InstanceContext } from "@/effect/instance-context"
|
|
||||||
import { Effect, Layer, ServiceMap } from "effect"
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
|
import type { Agent } from "@/agent/agent"
|
||||||
|
import { Bus } from "@/bus"
|
||||||
|
import { InstanceContext } from "@/effect/instance-context"
|
||||||
import { runPromiseInstance } from "@/effect/runtime"
|
import { runPromiseInstance } from "@/effect/runtime"
|
||||||
|
import { Flag } from "@/flag/flag"
|
||||||
const log = Log.create({ service: "skill" })
|
import { Global } from "@/global"
|
||||||
|
import { PermissionNext } from "@/permission"
|
||||||
// External skill directories to search for (project-level and global)
|
import { Filesystem } from "@/util/filesystem"
|
||||||
// These follow the directory layout used by Claude Code and other agents.
|
import { Config } from "../config/config"
|
||||||
const EXTERNAL_DIRS = [".claude", ".agents"]
|
import { ConfigMarkdown } from "../config/markdown"
|
||||||
const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
|
import { Glob } from "../util/glob"
|
||||||
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
|
import { Log } from "../util/log"
|
||||||
const SKILL_PATTERN = "**/SKILL.md"
|
import { Discovery } from "./discovery"
|
||||||
|
|
||||||
export namespace Skill {
|
export namespace Skill {
|
||||||
|
const log = Log.create({ service: "skill" })
|
||||||
|
const EXTERNAL_DIRS = [".claude", ".agents"]
|
||||||
|
const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
|
||||||
|
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
|
||||||
|
const SKILL_PATTERN = "**/SKILL.md"
|
||||||
|
|
||||||
export const Info = z.object({
|
export const Info = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
@@ -55,213 +51,205 @@ export namespace Skill {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
skills: Record<string, Info>
|
||||||
|
dirs: Set<string>
|
||||||
|
task?: Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
type Cache = State & {
|
||||||
|
ensure: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
||||||
|
readonly all: () => Effect.Effect<Info[]>
|
||||||
|
readonly dirs: () => Effect.Effect<string[]>
|
||||||
|
readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
const add = async (state: State, match: string) => {
|
||||||
|
const md = await ConfigMarkdown.parse(match).catch(async (err) => {
|
||||||
|
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||||
|
? err.data.message
|
||||||
|
: `Failed to parse skill ${match}`
|
||||||
|
const { Session } = await import("@/session")
|
||||||
|
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||||
|
log.error("failed to load skill", { skill: match, err })
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!md) return
|
||||||
|
|
||||||
|
const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
|
||||||
|
if (!parsed.success) return
|
||||||
|
|
||||||
|
if (state.skills[parsed.data.name]) {
|
||||||
|
log.warn("duplicate skill name", {
|
||||||
|
name: parsed.data.name,
|
||||||
|
existing: state.skills[parsed.data.name].location,
|
||||||
|
duplicate: match,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
state.dirs.add(path.dirname(match))
|
||||||
|
state.skills[parsed.data.name] = {
|
||||||
|
name: parsed.data.name,
|
||||||
|
description: parsed.data.description,
|
||||||
|
location: match,
|
||||||
|
content: md.content,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => {
|
||||||
|
return Glob.scan(pattern, {
|
||||||
|
cwd: root,
|
||||||
|
absolute: true,
|
||||||
|
include: "file",
|
||||||
|
symlink: true,
|
||||||
|
dot: opts?.dot,
|
||||||
|
})
|
||||||
|
.then((matches) => Promise.all(matches.map((match) => add(state, match))))
|
||||||
|
.catch((error) => {
|
||||||
|
if (!opts?.scope) throw error
|
||||||
|
log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Migrate to Effect
|
||||||
|
const create = (instance: InstanceContext.Shape, discovery: Discovery.Interface): Cache => {
|
||||||
|
const state: State = {
|
||||||
|
skills: {},
|
||||||
|
dirs: new Set<string>(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
|
||||||
|
for (const dir of EXTERNAL_DIRS) {
|
||||||
|
const root = path.join(Global.Path.home, dir)
|
||||||
|
if (!(await Filesystem.isDir(root))) continue
|
||||||
|
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
|
||||||
|
}
|
||||||
|
|
||||||
|
for await (const root of Filesystem.up({
|
||||||
|
targets: EXTERNAL_DIRS,
|
||||||
|
start: instance.directory,
|
||||||
|
stop: instance.project.worktree,
|
||||||
|
})) {
|
||||||
|
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dir of await Config.directories()) {
|
||||||
|
await scan(state, dir, OPENCODE_SKILL_PATTERN)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfg = await Config.get()
|
||||||
|
for (const item of cfg.skills?.paths ?? []) {
|
||||||
|
const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
|
||||||
|
const dir = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded)
|
||||||
|
if (!(await Filesystem.isDir(dir))) {
|
||||||
|
log.warn("skill path not found", { path: dir })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
await scan(state, dir, SKILL_PATTERN)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const url of cfg.skills?.urls ?? []) {
|
||||||
|
for (const dir of await Effect.runPromise(discovery.pull(url))) {
|
||||||
|
state.dirs.add(dir)
|
||||||
|
await scan(state, dir, SKILL_PATTERN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("init", { count: Object.keys(state.skills).length })
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensure = () => {
|
||||||
|
if (state.task) return state.task
|
||||||
|
state.task = load().catch((err) => {
|
||||||
|
state.task = undefined
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
return state.task
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...state, ensure }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
|
||||||
|
|
||||||
|
export const layer: Layer.Layer<Service, never, InstanceContext | Discovery.Service> = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const instance = yield* InstanceContext
|
||||||
|
const discovery = yield* Discovery.Service
|
||||||
|
const state = create(instance, discovery)
|
||||||
|
|
||||||
|
const get = Effect.fn("Skill.get")(function* (name: string) {
|
||||||
|
yield* Effect.promise(() => state.ensure())
|
||||||
|
return state.skills[name]
|
||||||
|
})
|
||||||
|
|
||||||
|
const all = Effect.fn("Skill.all")(function* () {
|
||||||
|
yield* Effect.promise(() => state.ensure())
|
||||||
|
return Object.values(state.skills)
|
||||||
|
})
|
||||||
|
|
||||||
|
const dirs = Effect.fn("Skill.dirs")(function* () {
|
||||||
|
yield* Effect.promise(() => state.ensure())
|
||||||
|
return Array.from(state.dirs)
|
||||||
|
})
|
||||||
|
|
||||||
|
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
|
||||||
|
yield* Effect.promise(() => state.ensure())
|
||||||
|
const list = Object.values(state.skills)
|
||||||
|
if (!agent) return list
|
||||||
|
return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny")
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ get, all, dirs, available })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer: Layer.Layer<Service, never, InstanceContext> = layer.pipe(
|
||||||
|
Layer.provide(Discovery.defaultLayer),
|
||||||
|
)
|
||||||
|
|
||||||
export async function get(name: string) {
|
export async function get(name: string) {
|
||||||
return runPromiseInstance(SkillService.use((s) => s.get(name)))
|
return runPromiseInstance(Service.use((skill) => skill.get(name)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function all() {
|
export async function all() {
|
||||||
return runPromiseInstance(SkillService.use((s) => s.all()))
|
return runPromiseInstance(Service.use((skill) => skill.all()))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function dirs() {
|
export async function dirs() {
|
||||||
return runPromiseInstance(SkillService.use((s) => s.dirs()))
|
return runPromiseInstance(Service.use((skill) => skill.dirs()))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function available(agent?: Agent.Info) {
|
export async function available(agent?: Agent.Info) {
|
||||||
return runPromiseInstance(SkillService.use((s) => s.available(agent)))
|
return runPromiseInstance(Service.use((skill) => skill.available(agent)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fmt(list: Info[], opts: { verbose: boolean }) {
|
export function fmt(list: Info[], opts: { verbose: boolean }) {
|
||||||
if (list.length === 0) {
|
if (list.length === 0) return "No skills are currently available."
|
||||||
return "No skills are currently available."
|
|
||||||
}
|
|
||||||
if (opts.verbose) {
|
if (opts.verbose) {
|
||||||
return [
|
return [
|
||||||
"<available_skills>",
|
"<available_skills>",
|
||||||
...list.flatMap((skill) => [
|
...list.flatMap((skill) => [
|
||||||
` <skill>`,
|
" <skill>",
|
||||||
` <name>${skill.name}</name>`,
|
` <name>${skill.name}</name>`,
|
||||||
` <description>${skill.description}</description>`,
|
` <description>${skill.description}</description>`,
|
||||||
` <location>${pathToFileURL(skill.location).href}</location>`,
|
` <location>${pathToFileURL(skill.location).href}</location>`,
|
||||||
` </skill>`,
|
" </skill>",
|
||||||
]),
|
]),
|
||||||
"</available_skills>",
|
"</available_skills>",
|
||||||
].join("\n")
|
].join("\n")
|
||||||
}
|
}
|
||||||
return ["## Available Skills", ...list.flatMap((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
|
|
||||||
|
return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export namespace SkillService {
|
|
||||||
export interface Service {
|
|
||||||
readonly get: (name: string) => Effect.Effect<Skill.Info | undefined>
|
|
||||||
readonly all: () => Effect.Effect<Skill.Info[]>
|
|
||||||
readonly dirs: () => Effect.Effect<string[]>
|
|
||||||
readonly available: (agent?: Agent.Info) => Effect.Effect<Skill.Info[]>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SkillService extends ServiceMap.Service<SkillService, SkillService.Service>()("@opencode/Skill") {
|
|
||||||
static readonly layer = Layer.effect(
|
|
||||||
SkillService,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const instance = yield* InstanceContext
|
|
||||||
const discovery = yield* DiscoveryService
|
|
||||||
|
|
||||||
const skills: Record<string, Skill.Info> = {}
|
|
||||||
const skillDirs = new Set<string>()
|
|
||||||
let task: Promise<void> | undefined
|
|
||||||
|
|
||||||
const addSkill = async (match: string) => {
|
|
||||||
const md = await ConfigMarkdown.parse(match).catch(async (err) => {
|
|
||||||
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
|
||||||
? err.data.message
|
|
||||||
: `Failed to parse skill ${match}`
|
|
||||||
const { Session } = await import("@/session")
|
|
||||||
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
|
||||||
log.error("failed to load skill", { skill: match, err })
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!md) return
|
|
||||||
|
|
||||||
const parsed = Skill.Info.pick({ name: true, description: true }).safeParse(md.data)
|
|
||||||
if (!parsed.success) return
|
|
||||||
|
|
||||||
// Warn on duplicate skill names
|
|
||||||
if (skills[parsed.data.name]) {
|
|
||||||
log.warn("duplicate skill name", {
|
|
||||||
name: parsed.data.name,
|
|
||||||
existing: skills[parsed.data.name].location,
|
|
||||||
duplicate: match,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
skillDirs.add(path.dirname(match))
|
|
||||||
|
|
||||||
skills[parsed.data.name] = {
|
|
||||||
name: parsed.data.name,
|
|
||||||
description: parsed.data.description,
|
|
||||||
location: match,
|
|
||||||
content: md.content,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const scanExternal = async (root: string, scope: "global" | "project") => {
|
|
||||||
return Glob.scan(EXTERNAL_SKILL_PATTERN, {
|
|
||||||
cwd: root,
|
|
||||||
absolute: true,
|
|
||||||
include: "file",
|
|
||||||
dot: true,
|
|
||||||
symlink: true,
|
|
||||||
})
|
|
||||||
.then((matches) => Promise.all(matches.map(addSkill)))
|
|
||||||
.catch((error) => {
|
|
||||||
log.error(`failed to scan ${scope} skills`, { dir: root, error })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureScanned() {
|
|
||||||
if (task) return task
|
|
||||||
task = (async () => {
|
|
||||||
// Scan external skill directories (.claude/skills/, .agents/skills/, etc.)
|
|
||||||
// Load global (home) first, then project-level (so project-level overwrites)
|
|
||||||
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
|
|
||||||
for (const dir of EXTERNAL_DIRS) {
|
|
||||||
const root = path.join(Global.Path.home, dir)
|
|
||||||
if (!(await Filesystem.isDir(root))) continue
|
|
||||||
await scanExternal(root, "global")
|
|
||||||
}
|
|
||||||
|
|
||||||
for await (const root of Filesystem.up({
|
|
||||||
targets: EXTERNAL_DIRS,
|
|
||||||
start: instance.directory,
|
|
||||||
stop: instance.project.worktree,
|
|
||||||
})) {
|
|
||||||
await scanExternal(root, "project")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scan .opencode/skill/ directories
|
|
||||||
for (const dir of await Config.directories()) {
|
|
||||||
const matches = await Glob.scan(OPENCODE_SKILL_PATTERN, {
|
|
||||||
cwd: dir,
|
|
||||||
absolute: true,
|
|
||||||
include: "file",
|
|
||||||
symlink: true,
|
|
||||||
})
|
|
||||||
for (const match of matches) {
|
|
||||||
await addSkill(match)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scan additional skill paths from config
|
|
||||||
const config = await Config.get()
|
|
||||||
for (const skillPath of config.skills?.paths ?? []) {
|
|
||||||
const expanded = skillPath.startsWith("~/") ? path.join(os.homedir(), skillPath.slice(2)) : skillPath
|
|
||||||
const resolved = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded)
|
|
||||||
if (!(await Filesystem.isDir(resolved))) {
|
|
||||||
log.warn("skill path not found", { path: resolved })
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const matches = await Glob.scan(SKILL_PATTERN, {
|
|
||||||
cwd: resolved,
|
|
||||||
absolute: true,
|
|
||||||
include: "file",
|
|
||||||
symlink: true,
|
|
||||||
})
|
|
||||||
for (const match of matches) {
|
|
||||||
await addSkill(match)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download and load skills from URLs
|
|
||||||
for (const url of config.skills?.urls ?? []) {
|
|
||||||
const list = await Effect.runPromise(discovery.pull(url))
|
|
||||||
for (const dir of list) {
|
|
||||||
skillDirs.add(dir)
|
|
||||||
const matches = await Glob.scan(SKILL_PATTERN, {
|
|
||||||
cwd: dir,
|
|
||||||
absolute: true,
|
|
||||||
include: "file",
|
|
||||||
symlink: true,
|
|
||||||
})
|
|
||||||
for (const match of matches) {
|
|
||||||
await addSkill(match)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("init", { count: Object.keys(skills).length })
|
|
||||||
})().catch((err) => {
|
|
||||||
task = undefined
|
|
||||||
throw err
|
|
||||||
})
|
|
||||||
return task
|
|
||||||
}
|
|
||||||
|
|
||||||
return SkillService.of({
|
|
||||||
get: Effect.fn("SkillService.get")(function* (name: string) {
|
|
||||||
yield* Effect.promise(() => ensureScanned())
|
|
||||||
return skills[name]
|
|
||||||
}),
|
|
||||||
all: Effect.fn("SkillService.all")(function* () {
|
|
||||||
yield* Effect.promise(() => ensureScanned())
|
|
||||||
return Object.values(skills)
|
|
||||||
}),
|
|
||||||
dirs: Effect.fn("SkillService.dirs")(function* () {
|
|
||||||
yield* Effect.promise(() => ensureScanned())
|
|
||||||
return Array.from(skillDirs)
|
|
||||||
}),
|
|
||||||
available: Effect.fn("SkillService.available")(function* (agent?: Agent.Info) {
|
|
||||||
yield* Effect.promise(() => ensureScanned())
|
|
||||||
const list = Object.values(skills)
|
|
||||||
if (!agent) return list
|
|
||||||
return list.filter(
|
|
||||||
(skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny",
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
).pipe(Layer.provide(DiscoveryService.defaultLayer))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,20 +9,6 @@ import { Config } from "../config/config"
|
|||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
|
|
||||||
const log = Log.create({ service: "snapshot" })
|
|
||||||
const PRUNE = "7.days"
|
|
||||||
|
|
||||||
// Common git config flags shared across snapshot operations
|
|
||||||
const GIT_CORE = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
|
|
||||||
const GIT_CFG = ["-c", "core.autocrlf=false", ...GIT_CORE]
|
|
||||||
const GIT_CFG_QUOTE = [...GIT_CFG, "-c", "core.quotepath=false"]
|
|
||||||
|
|
||||||
interface GitResult {
|
|
||||||
readonly code: ChildProcessSpawner.ExitCode
|
|
||||||
readonly text: string
|
|
||||||
readonly stderr: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export namespace Snapshot {
|
export namespace Snapshot {
|
||||||
export const Patch = z.object({
|
export const Patch = z.object({
|
||||||
hash: z.string(),
|
hash: z.string(),
|
||||||
@@ -44,43 +30,47 @@ export namespace Snapshot {
|
|||||||
})
|
})
|
||||||
export type FileDiff = z.infer<typeof FileDiff>
|
export type FileDiff = z.infer<typeof FileDiff>
|
||||||
|
|
||||||
// Promise facade — existing callers use these
|
|
||||||
export function init() {
|
|
||||||
void runPromiseInstance(SnapshotService.use((s) => s.init()))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cleanup() {
|
export async function cleanup() {
|
||||||
return runPromiseInstance(SnapshotService.use((s) => s.cleanup()))
|
return runPromiseInstance(Service.use((svc) => svc.cleanup()))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function track() {
|
export async function track() {
|
||||||
return runPromiseInstance(SnapshotService.use((s) => s.track()))
|
return runPromiseInstance(Service.use((svc) => svc.track()))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function patch(hash: string) {
|
export async function patch(hash: string) {
|
||||||
return runPromiseInstance(SnapshotService.use((s) => s.patch(hash)))
|
return runPromiseInstance(Service.use((svc) => svc.patch(hash)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function restore(snapshot: string) {
|
export async function restore(snapshot: string) {
|
||||||
return runPromiseInstance(SnapshotService.use((s) => s.restore(snapshot)))
|
return runPromiseInstance(Service.use((svc) => svc.restore(snapshot)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function revert(patches: Patch[]) {
|
export async function revert(patches: Patch[]) {
|
||||||
return runPromiseInstance(SnapshotService.use((s) => s.revert(patches)))
|
return runPromiseInstance(Service.use((svc) => svc.revert(patches)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function diff(hash: string) {
|
export async function diff(hash: string) {
|
||||||
return runPromiseInstance(SnapshotService.use((s) => s.diff(hash)))
|
return runPromiseInstance(Service.use((svc) => svc.diff(hash)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function diffFull(from: string, to: string) {
|
export async function diffFull(from: string, to: string) {
|
||||||
return runPromiseInstance(SnapshotService.use((s) => s.diffFull(from, to)))
|
return runPromiseInstance(Service.use((svc) => svc.diffFull(from, to)))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export namespace SnapshotService {
|
const log = Log.create({ service: "snapshot" })
|
||||||
export interface Service {
|
const prune = "7.days"
|
||||||
readonly init: () => Effect.Effect<void>
|
const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
|
||||||
|
const cfg = ["-c", "core.autocrlf=false", ...core]
|
||||||
|
const quote = [...cfg, "-c", "core.quotepath=false"]
|
||||||
|
|
||||||
|
interface GitResult {
|
||||||
|
readonly code: ChildProcessSpawner.ExitCode
|
||||||
|
readonly text: string
|
||||||
|
readonly stderr: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
readonly cleanup: () => Effect.Effect<void>
|
readonly cleanup: () => Effect.Effect<void>
|
||||||
readonly track: () => Effect.Effect<string | undefined>
|
readonly track: () => Effect.Effect<string | undefined>
|
||||||
readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch>
|
readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch>
|
||||||
@@ -89,99 +79,92 @@ export namespace SnapshotService {
|
|||||||
readonly diff: (hash: string) => Effect.Effect<string>
|
readonly diff: (hash: string) => Effect.Effect<string>
|
||||||
readonly diffFull: (from: string, to: string) => Effect.Effect<Snapshot.FileDiff[]>
|
readonly diffFull: (from: string, to: string) => Effect.Effect<Snapshot.FileDiff[]>
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export class SnapshotService extends ServiceMap.Service<SnapshotService, SnapshotService.Service>()(
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Snapshot") {}
|
||||||
"@opencode/Snapshot",
|
|
||||||
) {
|
export const layer: Layer.Layer<
|
||||||
static readonly layer = Layer.effect(
|
Service,
|
||||||
SnapshotService,
|
never,
|
||||||
|
InstanceContext | FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner
|
||||||
|
> = Layer.effect(
|
||||||
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const ctx = yield* InstanceContext
|
const ctx = yield* InstanceContext
|
||||||
const fileSystem = yield* FileSystem.FileSystem
|
const fs = yield* FileSystem.FileSystem
|
||||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||||
const { directory, worktree, project } = ctx
|
const directory = ctx.directory
|
||||||
const isGit = project.vcs === "git"
|
const worktree = ctx.worktree
|
||||||
const snapshotGit = path.join(Global.Path.data, "snapshot", project.id)
|
const project = ctx.project
|
||||||
|
const gitdir = path.join(Global.Path.data, "snapshot", project.id)
|
||||||
|
|
||||||
const gitArgs = (cmd: string[]) => ["--git-dir", snapshotGit, "--work-tree", worktree, ...cmd]
|
const args = (cmd: string[]) => ["--git-dir", gitdir, "--work-tree", worktree, ...cmd]
|
||||||
|
|
||||||
// Run git with nothrow semantics — always returns a result, never fails
|
const git = Effect.fnUntraced(
|
||||||
const git = (args: string[], opts?: { cwd?: string; env?: Record<string, string> }): Effect.Effect<GitResult> =>
|
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
|
||||||
Effect.gen(function* () {
|
const proc = ChildProcess.make("git", cmd, {
|
||||||
const command = ChildProcess.make("git", args, {
|
|
||||||
cwd: opts?.cwd,
|
cwd: opts?.cwd,
|
||||||
env: opts?.env,
|
env: opts?.env,
|
||||||
extendEnv: true,
|
extendEnv: true,
|
||||||
})
|
})
|
||||||
const handle = yield* spawner.spawn(command)
|
const handle = yield* spawner.spawn(proc)
|
||||||
const [text, stderr] = yield* Effect.all(
|
const [text, stderr] = yield* Effect.all(
|
||||||
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
||||||
{ concurrency: 2 },
|
{ concurrency: 2 },
|
||||||
)
|
)
|
||||||
const code = yield* handle.exitCode
|
const code = yield* handle.exitCode
|
||||||
return { code, text, stderr }
|
return { code, text, stderr } satisfies GitResult
|
||||||
}).pipe(
|
},
|
||||||
Effect.scoped,
|
Effect.scoped,
|
||||||
Effect.catch((err) =>
|
Effect.catch((err) =>
|
||||||
Effect.succeed({
|
Effect.succeed({
|
||||||
code: ChildProcessSpawner.ExitCode(1),
|
code: ChildProcessSpawner.ExitCode(1),
|
||||||
text: "",
|
text: "",
|
||||||
stderr: String(err),
|
stderr: String(err),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
// FileSystem helpers — orDie converts PlatformError to defects
|
const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
|
||||||
const exists = (p: string) => fileSystem.exists(p).pipe(Effect.orDie)
|
const mkdir = (dir: string) => fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie)
|
||||||
const mkdir = (p: string) => fileSystem.makeDirectory(p, { recursive: true }).pipe(Effect.orDie)
|
const write = (file: string, text: string) => fs.writeFileString(file, text).pipe(Effect.orDie)
|
||||||
const writeFile = (p: string, content: string) => fileSystem.writeFileString(p, content).pipe(Effect.orDie)
|
const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
|
||||||
const readFile = (p: string) => fileSystem.readFileString(p).pipe(Effect.catch(() => Effect.succeed("")))
|
const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
|
||||||
const removeFile = (p: string) => fileSystem.remove(p).pipe(Effect.catch(() => Effect.void))
|
|
||||||
|
|
||||||
// --- internal Effect helpers ---
|
const enabled = Effect.fnUntraced(function* () {
|
||||||
|
if (project.vcs !== "git") return false
|
||||||
const isEnabled = Effect.gen(function* () {
|
return (yield* Effect.promise(() => Config.get())).snapshot !== false
|
||||||
if (!isGit) return false
|
|
||||||
const cfg = yield* Effect.promise(() => Config.get())
|
|
||||||
return cfg.snapshot !== false
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const excludesPath = Effect.gen(function* () {
|
const excludes = Effect.fnUntraced(function* () {
|
||||||
const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
|
const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
|
||||||
cwd: worktree,
|
cwd: worktree,
|
||||||
})
|
})
|
||||||
const file = result.text.trim()
|
const file = result.text.trim()
|
||||||
if (!file) return undefined
|
if (!file) return
|
||||||
if (!(yield* exists(file))) return undefined
|
if (!(yield* exists(file))) return
|
||||||
return file
|
return file
|
||||||
})
|
})
|
||||||
|
|
||||||
const syncExclude = Effect.gen(function* () {
|
const sync = Effect.fnUntraced(function* () {
|
||||||
const file = yield* excludesPath
|
const file = yield* excludes()
|
||||||
const target = path.join(snapshotGit, "info", "exclude")
|
const target = path.join(gitdir, "info", "exclude")
|
||||||
yield* mkdir(path.join(snapshotGit, "info"))
|
yield* mkdir(path.join(gitdir, "info"))
|
||||||
if (!file) {
|
if (!file) {
|
||||||
yield* writeFile(target, "")
|
yield* write(target, "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const text = yield* readFile(file)
|
yield* write(target, yield* read(file))
|
||||||
yield* writeFile(target, text)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const add = Effect.gen(function* () {
|
const add = Effect.fnUntraced(function* () {
|
||||||
yield* syncExclude
|
yield* sync()
|
||||||
yield* git([...GIT_CFG, ...gitArgs(["add", "."])], { cwd: directory })
|
yield* git([...cfg, ...args(["add", "."])], { cwd: directory })
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- service methods ---
|
const cleanup = Effect.fn("Snapshot.cleanup")(function* () {
|
||||||
|
if (!(yield* enabled())) return
|
||||||
const cleanup = Effect.fn("SnapshotService.cleanup")(function* () {
|
if (!(yield* exists(gitdir))) return
|
||||||
if (!(yield* isEnabled)) return
|
const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: directory })
|
||||||
if (!(yield* exists(snapshotGit))) return
|
|
||||||
const result = yield* git(gitArgs(["gc", `--prune=${PRUNE}`]), {
|
|
||||||
cwd: directory,
|
|
||||||
})
|
|
||||||
if (result.code !== 0) {
|
if (result.code !== 0) {
|
||||||
log.warn("cleanup failed", {
|
log.warn("cleanup failed", {
|
||||||
exitCode: result.code,
|
exitCode: result.code,
|
||||||
@@ -189,58 +172,55 @@ export class SnapshotService extends ServiceMap.Service<SnapshotService, Snapsho
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.info("cleanup", { prune: PRUNE })
|
log.info("cleanup", { prune })
|
||||||
})
|
})
|
||||||
|
|
||||||
const track = Effect.fn("SnapshotService.track")(function* () {
|
const track = Effect.fn("Snapshot.track")(function* () {
|
||||||
if (!(yield* isEnabled)) return undefined
|
if (!(yield* enabled())) return
|
||||||
const existed = yield* exists(snapshotGit)
|
const existed = yield* exists(gitdir)
|
||||||
yield* mkdir(snapshotGit)
|
yield* mkdir(gitdir)
|
||||||
if (!existed) {
|
if (!existed) {
|
||||||
yield* git(["init"], {
|
yield* git(["init"], {
|
||||||
env: { GIT_DIR: snapshotGit, GIT_WORK_TREE: worktree },
|
env: { GIT_DIR: gitdir, GIT_WORK_TREE: worktree },
|
||||||
})
|
})
|
||||||
yield* git(["--git-dir", snapshotGit, "config", "core.autocrlf", "false"])
|
yield* git(["--git-dir", gitdir, "config", "core.autocrlf", "false"])
|
||||||
yield* git(["--git-dir", snapshotGit, "config", "core.longpaths", "true"])
|
yield* git(["--git-dir", gitdir, "config", "core.longpaths", "true"])
|
||||||
yield* git(["--git-dir", snapshotGit, "config", "core.symlinks", "true"])
|
yield* git(["--git-dir", gitdir, "config", "core.symlinks", "true"])
|
||||||
yield* git(["--git-dir", snapshotGit, "config", "core.fsmonitor", "false"])
|
yield* git(["--git-dir", gitdir, "config", "core.fsmonitor", "false"])
|
||||||
log.info("initialized")
|
log.info("initialized")
|
||||||
}
|
}
|
||||||
yield* add
|
yield* add()
|
||||||
const result = yield* git(gitArgs(["write-tree"]), { cwd: directory })
|
const result = yield* git(args(["write-tree"]), { cwd: directory })
|
||||||
const hash = result.text.trim()
|
const hash = result.text.trim()
|
||||||
log.info("tracking", { hash, cwd: directory, git: snapshotGit })
|
log.info("tracking", { hash, cwd: directory, git: gitdir })
|
||||||
return hash
|
return hash
|
||||||
})
|
})
|
||||||
|
|
||||||
const patch = Effect.fn("SnapshotService.patch")(function* (hash: string) {
|
const patch = Effect.fn("Snapshot.patch")(function* (hash: string) {
|
||||||
yield* add
|
yield* add()
|
||||||
const result = yield* git(
|
const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])], {
|
||||||
[...GIT_CFG_QUOTE, ...gitArgs(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])],
|
cwd: directory,
|
||||||
{ cwd: directory },
|
})
|
||||||
)
|
|
||||||
|
|
||||||
if (result.code !== 0) {
|
if (result.code !== 0) {
|
||||||
log.warn("failed to get diff", { hash, exitCode: result.code })
|
log.warn("failed to get diff", { hash, exitCode: result.code })
|
||||||
return { hash, files: [] } as Snapshot.Patch
|
return { hash, files: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hash,
|
hash,
|
||||||
files: result.text
|
files: result.text
|
||||||
.trim()
|
.trim()
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((x: string) => x.trim())
|
.map((x) => x.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((x: string) => path.join(worktree, x).replaceAll("\\", "/")),
|
.map((x) => path.join(worktree, x).replaceAll("\\", "/")),
|
||||||
} as Snapshot.Patch
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const restore = Effect.fn("SnapshotService.restore")(function* (snapshot: string) {
|
const restore = Effect.fn("Snapshot.restore")(function* (snapshot: string) {
|
||||||
log.info("restore", { commit: snapshot })
|
log.info("restore", { commit: snapshot })
|
||||||
const result = yield* git([...GIT_CORE, ...gitArgs(["read-tree", snapshot])], { cwd: worktree })
|
const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: worktree })
|
||||||
if (result.code === 0) {
|
if (result.code === 0) {
|
||||||
const checkout = yield* git([...GIT_CORE, ...gitArgs(["checkout-index", "-a", "-f"])], { cwd: worktree })
|
const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { cwd: worktree })
|
||||||
if (checkout.code === 0) return
|
if (checkout.code === 0) return
|
||||||
log.error("failed to restore snapshot", {
|
log.error("failed to restore snapshot", {
|
||||||
snapshot,
|
snapshot,
|
||||||
@@ -256,38 +236,33 @@ export class SnapshotService extends ServiceMap.Service<SnapshotService, Snapsho
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const revert = Effect.fn("SnapshotService.revert")(function* (patches: Snapshot.Patch[]) {
|
const revert = Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
for (const item of patches) {
|
for (const item of patches) {
|
||||||
for (const file of item.files) {
|
for (const file of item.files) {
|
||||||
if (seen.has(file)) continue
|
if (seen.has(file)) continue
|
||||||
|
seen.add(file)
|
||||||
log.info("reverting", { file, hash: item.hash })
|
log.info("reverting", { file, hash: item.hash })
|
||||||
const result = yield* git([...GIT_CORE, ...gitArgs(["checkout", item.hash, "--", file])], {
|
const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], { cwd: worktree })
|
||||||
cwd: worktree,
|
|
||||||
})
|
|
||||||
if (result.code !== 0) {
|
if (result.code !== 0) {
|
||||||
const relativePath = path.relative(worktree, file)
|
const rel = path.relative(worktree, file)
|
||||||
const checkTree = yield* git([...GIT_CORE, ...gitArgs(["ls-tree", item.hash, "--", relativePath])], {
|
const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], { cwd: worktree })
|
||||||
cwd: worktree,
|
if (tree.code === 0 && tree.text.trim()) {
|
||||||
})
|
|
||||||
if (checkTree.code === 0 && checkTree.text.trim()) {
|
|
||||||
log.info("file existed in snapshot but checkout failed, keeping", { file })
|
log.info("file existed in snapshot but checkout failed, keeping", { file })
|
||||||
} else {
|
} else {
|
||||||
log.info("file did not exist in snapshot, deleting", { file })
|
log.info("file did not exist in snapshot, deleting", { file })
|
||||||
yield* removeFile(file)
|
yield* remove(file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
seen.add(file)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const diff = Effect.fn("SnapshotService.diff")(function* (hash: string) {
|
const diff = Effect.fn("Snapshot.diff")(function* (hash: string) {
|
||||||
yield* add
|
yield* add()
|
||||||
const result = yield* git([...GIT_CFG_QUOTE, ...gitArgs(["diff", "--no-ext-diff", hash, "--", "."])], {
|
const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], {
|
||||||
cwd: worktree,
|
cwd: worktree,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (result.code !== 0) {
|
if (result.code !== 0) {
|
||||||
log.warn("failed to get diff", {
|
log.warn("failed to get diff", {
|
||||||
hash,
|
hash,
|
||||||
@@ -296,19 +271,15 @@ export class SnapshotService extends ServiceMap.Service<SnapshotService, Snapsho
|
|||||||
})
|
})
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.text.trim()
|
return result.text.trim()
|
||||||
})
|
})
|
||||||
|
|
||||||
const diffFull = Effect.fn("SnapshotService.diffFull")(function* (from: string, to: string) {
|
const diffFull = Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
|
||||||
const result: Snapshot.FileDiff[] = []
|
const result: Snapshot.FileDiff[] = []
|
||||||
const status = new Map<string, "added" | "deleted" | "modified">()
|
const status = new Map<string, "added" | "deleted" | "modified">()
|
||||||
|
|
||||||
const statuses = yield* git(
|
const statuses = yield* git(
|
||||||
[
|
[...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
|
||||||
...GIT_CFG_QUOTE,
|
|
||||||
...gitArgs(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]),
|
|
||||||
],
|
|
||||||
{ cwd: directory },
|
{ cwd: directory },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -316,64 +287,60 @@ export class SnapshotService extends ServiceMap.Service<SnapshotService, Snapsho
|
|||||||
if (!line) continue
|
if (!line) continue
|
||||||
const [code, file] = line.split("\t")
|
const [code, file] = line.split("\t")
|
||||||
if (!code || !file) continue
|
if (!code || !file) continue
|
||||||
const kind = code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified"
|
status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
|
||||||
status.set(file, kind)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const numstat = yield* git(
|
const numstat = yield* git(
|
||||||
[...GIT_CFG_QUOTE, ...gitArgs(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
|
[...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
|
||||||
{ cwd: directory },
|
{
|
||||||
|
cwd: directory,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
for (const line of numstat.text.trim().split("\n")) {
|
for (const line of numstat.text.trim().split("\n")) {
|
||||||
if (!line) continue
|
if (!line) continue
|
||||||
const [additions, deletions, file] = line.split("\t")
|
const [adds, dels, file] = line.split("\t")
|
||||||
const isBinaryFile = additions === "-" && deletions === "-"
|
if (!file) continue
|
||||||
const [before, after] = isBinaryFile
|
const binary = adds === "-" && dels === "-"
|
||||||
|
const [before, after] = binary
|
||||||
? ["", ""]
|
? ["", ""]
|
||||||
: yield* Effect.all(
|
: yield* Effect.all(
|
||||||
[
|
[
|
||||||
git([...GIT_CFG, ...gitArgs(["show", `${from}:${file}`])]).pipe(Effect.map((r) => r.text)),
|
git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
||||||
git([...GIT_CFG, ...gitArgs(["show", `${to}:${file}`])]).pipe(Effect.map((r) => r.text)),
|
git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
||||||
],
|
],
|
||||||
{ concurrency: 2 },
|
{ concurrency: 2 },
|
||||||
)
|
)
|
||||||
const added = isBinaryFile ? 0 : parseInt(additions!)
|
const additions = binary ? 0 : parseInt(adds)
|
||||||
const deleted = isBinaryFile ? 0 : parseInt(deletions!)
|
const deletions = binary ? 0 : parseInt(dels)
|
||||||
result.push({
|
result.push({
|
||||||
file: file!,
|
file,
|
||||||
before,
|
before,
|
||||||
after,
|
after,
|
||||||
additions: Number.isFinite(added) ? added : 0,
|
additions: Number.isFinite(additions) ? additions : 0,
|
||||||
deletions: Number.isFinite(deleted) ? deleted : 0,
|
deletions: Number.isFinite(deletions) ? deletions : 0,
|
||||||
status: status.get(file!) ?? "modified",
|
status: status.get(file) ?? "modified",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
// Start hourly cleanup fiber — scoped to instance lifetime
|
|
||||||
yield* cleanup().pipe(
|
yield* cleanup().pipe(
|
||||||
Effect.catchCause((cause) => {
|
Effect.catchCause((cause) => {
|
||||||
log.error("cleanup loop failed", { cause: Cause.pretty(cause) })
|
log.error("cleanup loop failed", { cause: Cause.pretty(cause) })
|
||||||
return Effect.void
|
return Effect.void
|
||||||
}),
|
}),
|
||||||
Effect.repeat(Schedule.spaced(Duration.hours(1))),
|
Effect.repeat(Schedule.spaced(Duration.hours(1))),
|
||||||
|
Effect.delay(Duration.minutes(1)),
|
||||||
Effect.forkScoped,
|
Effect.forkScoped,
|
||||||
)
|
)
|
||||||
|
|
||||||
return SnapshotService.of({
|
return Service.of({ cleanup, track, patch, restore, revert, diff, diffFull })
|
||||||
init: Effect.fn("SnapshotService.init")(function* () {}),
|
|
||||||
cleanup,
|
|
||||||
track,
|
|
||||||
patch,
|
|
||||||
restore,
|
|
||||||
revert,
|
|
||||||
diff,
|
|
||||||
diffFull,
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
).pipe(
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(
|
||||||
Layer.provide(NodeChildProcessSpawner.layer),
|
Layer.provide(NodeChildProcessSpawner.layer),
|
||||||
Layer.provide(NodeFileSystem.layer),
|
Layer.provide(NodeFileSystem.layer),
|
||||||
Layer.provide(NodePath.layer),
|
Layer.provide(NodePath.layer),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
|||||||
import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap } from "effect"
|
import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap } from "effect"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import type { Agent } from "../agent/agent"
|
import type { Agent } from "../agent/agent"
|
||||||
import { PermissionEffect } from "../permission/service"
|
import { PermissionNext } from "../permission"
|
||||||
import { Identifier } from "../id/id"
|
import { Identifier } from "../id/id"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { ToolID } from "./schema"
|
import { ToolID } from "./schema"
|
||||||
@@ -27,10 +27,10 @@ export namespace TruncateEffect {
|
|||||||
|
|
||||||
function hasTaskTool(agent?: Agent.Info) {
|
function hasTaskTool(agent?: Agent.Info) {
|
||||||
if (!agent?.permission) return false
|
if (!agent?.permission) return false
|
||||||
return PermissionEffect.evaluate("task", "*", agent.permission).action !== "deny"
|
return PermissionNext.evaluate("task", "*", agent.permission).action !== "deny"
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Api {
|
export interface Interface {
|
||||||
readonly cleanup: () => Effect.Effect<void>
|
readonly cleanup: () => Effect.Effect<void>
|
||||||
/**
|
/**
|
||||||
* Returns output unchanged when it fits within the limits, otherwise writes the full text
|
* Returns output unchanged when it fits within the limits, otherwise writes the full text
|
||||||
@@ -39,14 +39,14 @@ export namespace TruncateEffect {
|
|||||||
readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
|
readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Api>()("@opencode/Truncate") {}
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Truncate") {}
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* FileSystem.FileSystem
|
const fs = yield* FileSystem.FileSystem
|
||||||
|
|
||||||
const cleanup = Effect.fn("TruncateEffect.cleanup")(function* () {
|
const cleanup = Effect.fn("Truncate.cleanup")(function* () {
|
||||||
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION)))
|
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION)))
|
||||||
const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe(
|
const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe(
|
||||||
Effect.map((all) => all.filter((name) => name.startsWith("tool_"))),
|
Effect.map((all) => all.filter((name) => name.startsWith("tool_"))),
|
||||||
@@ -58,7 +58,7 @@ export namespace TruncateEffect {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const output = Effect.fn("TruncateEffect.output")(function* (
|
const output = Effect.fn("Truncate.output")(function* (
|
||||||
text: string,
|
text: string,
|
||||||
options: Options = {},
|
options: Options = {},
|
||||||
agent?: Agent.Info,
|
agent?: Agent.Info,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Duration, Effect, Layer, Option, Schema } from "effect"
|
|||||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||||
|
|
||||||
import { AccountRepo } from "../../src/account/repo"
|
import { AccountRepo } from "../../src/account/repo"
|
||||||
import { AccountService } from "../../src/account/service"
|
import { AccountEffect } from "../../src/account/effect"
|
||||||
import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema"
|
import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema"
|
||||||
import { Database } from "../../src/storage/db"
|
import { Database } from "../../src/storage/db"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
@@ -19,7 +19,7 @@ const truncate = Layer.effectDiscard(
|
|||||||
const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
|
const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
|
||||||
|
|
||||||
const live = (client: HttpClient.HttpClient) =>
|
const live = (client: HttpClient.HttpClient) =>
|
||||||
AccountService.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client)))
|
AccountEffect.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client)))
|
||||||
|
|
||||||
const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) =>
|
const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) =>
|
||||||
HttpClientResponse.fromWeb(
|
HttpClientResponse.fromWeb(
|
||||||
@@ -77,7 +77,7 @@ it.effect("orgsByAccount groups orgs per account", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const rows = yield* AccountService.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client)))
|
const rows = yield* AccountEffect.Service.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client)))
|
||||||
|
|
||||||
expect(rows.map((row) => [row.account.id, row.orgs.map((org) => org.id)]).map(([id, orgs]) => [id, orgs])).toEqual([
|
expect(rows.map((row) => [row.account.id, row.orgs.map((org) => org.id)]).map(([id, orgs]) => [id, orgs])).toEqual([
|
||||||
[AccountID.make("user-1"), [OrgID.make("org-1")]],
|
[AccountID.make("user-1"), [OrgID.make("org-1")]],
|
||||||
@@ -115,7 +115,7 @@ it.effect("token refresh persists the new token", () =>
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const token = yield* AccountService.use((s) => s.token(id)).pipe(Effect.provide(live(client)))
|
const token = yield* AccountEffect.Service.use((s) => s.token(id)).pipe(Effect.provide(live(client)))
|
||||||
|
|
||||||
expect(Option.getOrThrow(token)).toBeDefined()
|
expect(Option.getOrThrow(token)).toBeDefined()
|
||||||
expect(String(Option.getOrThrow(token))).toBe("at_new")
|
expect(String(Option.getOrThrow(token))).toBe("at_new")
|
||||||
@@ -158,7 +158,9 @@ it.effect("config sends the selected org header", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const cfg = yield* AccountService.use((s) => s.config(id, OrgID.make("org-9"))).pipe(Effect.provide(live(client)))
|
const cfg = yield* AccountEffect.Service.use((s) => s.config(id, OrgID.make("org-9"))).pipe(
|
||||||
|
Effect.provide(live(client)),
|
||||||
|
)
|
||||||
|
|
||||||
expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 })
|
expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 })
|
||||||
expect(seen).toEqual({
|
expect(seen).toEqual({
|
||||||
@@ -196,7 +198,7 @@ it.effect("poll stores the account and first org on success", () =>
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const res = yield* AccountService.use((s) => s.poll(login)).pipe(Effect.provide(live(client)))
|
const res = yield* AccountEffect.Service.use((s) => s.poll(login)).pipe(Effect.provide(live(client)))
|
||||||
|
|
||||||
expect(res._tag).toBe("PollSuccess")
|
expect(res._tag).toBe("PollSuccess")
|
||||||
if (res._tag === "PollSuccess") {
|
if (res._tag === "PollSuccess") {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import path from "path"
|
|||||||
import { Deferred, Effect, Fiber, Option } from "effect"
|
import { Deferred, Effect, Fiber, Option } from "effect"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import { watcherConfigLayer, withServices } from "../fixture/instance"
|
import { watcherConfigLayer, withServices } from "../fixture/instance"
|
||||||
import { FileWatcher, FileWatcherService } from "../../src/file/watcher"
|
import { FileWatcher } from "../../src/file/watcher"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { GlobalBus } from "../../src/bus/global"
|
import { GlobalBus } from "../../src/bus/global"
|
||||||
|
|
||||||
@@ -19,13 +19,12 @@ const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? desc
|
|||||||
type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } }
|
type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } }
|
||||||
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
|
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
|
||||||
|
|
||||||
/** Run `body` with a live FileWatcherService. */
|
/** Run `body` with a live FileWatcher service. */
|
||||||
function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
|
function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
|
||||||
return withServices(
|
return withServices(
|
||||||
directory,
|
directory,
|
||||||
FileWatcherService.layer,
|
FileWatcher.layer,
|
||||||
async (rt) => {
|
async (rt) => {
|
||||||
await rt.runPromise(FileWatcherService.use((s) => s.init()))
|
|
||||||
await Effect.runPromise(ready(directory))
|
await Effect.runPromise(ready(directory))
|
||||||
await Effect.runPromise(body)
|
await Effect.runPromise(body)
|
||||||
},
|
},
|
||||||
@@ -138,7 +137,7 @@ function ready(directory: string) {
|
|||||||
// Tests
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describeWatcher("FileWatcherService", () => {
|
describeWatcher("FileWatcher", () => {
|
||||||
afterEach(() => Instance.disposeAll())
|
afterEach(() => Instance.disposeAll())
|
||||||
|
|
||||||
test("publishes root create, update, and delete events", async () => {
|
test("publishes root create, update, and delete events", async () => {
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
|
import { Effect } from "effect"
|
||||||
import { afterEach, describe, expect, test } from "bun:test"
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import { withServices } from "../fixture/instance"
|
import { withServices } from "../fixture/instance"
|
||||||
import { FormatService } from "../../src/format"
|
import { Format } from "../../src/format"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
|
|
||||||
describe("FormatService", () => {
|
describe("Format", () => {
|
||||||
afterEach(() => Instance.disposeAll())
|
afterEach(() => Instance.disposeAll())
|
||||||
|
|
||||||
test("status() returns built-in formatters when no config overrides", async () => {
|
test("status() returns built-in formatters when no config overrides", async () => {
|
||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
|
|
||||||
await withServices(tmp.path, FormatService.layer, async (rt) => {
|
await withServices(tmp.path, Format.layer, async (rt) => {
|
||||||
const statuses = await rt.runPromise(FormatService.use((s) => s.status()))
|
const statuses = await rt.runPromise(Format.Service.use((s) => s.status()))
|
||||||
expect(Array.isArray(statuses)).toBe(true)
|
expect(Array.isArray(statuses)).toBe(true)
|
||||||
expect(statuses.length).toBeGreaterThan(0)
|
expect(statuses.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
@@ -32,8 +33,8 @@ describe("FormatService", () => {
|
|||||||
config: { formatter: false },
|
config: { formatter: false },
|
||||||
})
|
})
|
||||||
|
|
||||||
await withServices(tmp.path, FormatService.layer, async (rt) => {
|
await withServices(tmp.path, Format.layer, async (rt) => {
|
||||||
const statuses = await rt.runPromise(FormatService.use((s) => s.status()))
|
const statuses = await rt.runPromise(Format.Service.use((s) => s.status()))
|
||||||
expect(statuses).toEqual([])
|
expect(statuses).toEqual([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -47,18 +48,18 @@ describe("FormatService", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await withServices(tmp.path, FormatService.layer, async (rt) => {
|
await withServices(tmp.path, Format.layer, async (rt) => {
|
||||||
const statuses = await rt.runPromise(FormatService.use((s) => s.status()))
|
const statuses = await rt.runPromise(Format.Service.use((s) => s.status()))
|
||||||
const gofmt = statuses.find((s) => s.name === "gofmt")
|
const gofmt = statuses.find((s) => s.name === "gofmt")
|
||||||
expect(gofmt).toBeUndefined()
|
expect(gofmt).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("init() completes without error", async () => {
|
test("service initializes without error", async () => {
|
||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
|
|
||||||
await withServices(tmp.path, FormatService.layer, async (rt) => {
|
await withServices(tmp.path, Format.layer, async (rt) => {
|
||||||
await rt.runPromise(FormatService.use((s) => s.init()))
|
await rt.runPromise(Format.Service.use(() => Effect.void))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Bus } from "../../src/bus"
|
|||||||
import { runtime } from "../../src/effect/runtime"
|
import { runtime } from "../../src/effect/runtime"
|
||||||
import { Instances } from "../../src/effect/instances"
|
import { Instances } from "../../src/effect/instances"
|
||||||
import { PermissionNext } from "../../src/permission"
|
import { PermissionNext } from "../../src/permission"
|
||||||
import * as S from "../../src/permission/service"
|
import { PermissionNext as S } from "../../src/permission"
|
||||||
import { PermissionID } from "../../src/permission/schema"
|
import { PermissionID } from "../../src/permission/schema"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
@@ -1005,7 +1005,7 @@ test("ask - abort should clear pending request", async () => {
|
|||||||
fn: async () => {
|
fn: async () => {
|
||||||
const ctl = new AbortController()
|
const ctl = new AbortController()
|
||||||
const ask = runtime.runPromise(
|
const ask = runtime.runPromise(
|
||||||
S.PermissionEffect.Service.use((svc) =>
|
S.Service.use((svc) =>
|
||||||
svc.ask({
|
svc.ask({
|
||||||
sessionID: SessionID.make("session_test"),
|
sessionID: SessionID.make("session_test"),
|
||||||
permission: "bash",
|
permission: "bash",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import fs from "fs/promises"
|
|||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { ProviderAuth } from "../../src/provider/auth"
|
import { ProviderAuth } from "../../src/provider/auth"
|
||||||
|
import { ProviderID } from "../../src/provider/schema"
|
||||||
|
|
||||||
describe("plugin.auth-override", () => {
|
describe("plugin.auth-override", () => {
|
||||||
test("user plugin overrides built-in github-copilot auth", async () => {
|
test("user plugin overrides built-in github-copilot auth", async () => {
|
||||||
@@ -34,7 +35,7 @@ describe("plugin.auth-override", () => {
|
|||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const methods = await ProviderAuth.methods()
|
const methods = await ProviderAuth.methods()
|
||||||
const copilot = methods["github-copilot"]
|
const copilot = methods[ProviderID.make("github-copilot")]
|
||||||
expect(copilot).toBeDefined()
|
expect(copilot).toBeDefined()
|
||||||
expect(copilot.length).toBe(1)
|
expect(copilot.length).toBe(1)
|
||||||
expect(copilot[0].label).toBe("Test Override Auth")
|
expect(copilot[0].label).toBe("Test Override Auth")
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { $ } from "bun"
|
|||||||
import { afterEach, describe, expect, test } from "bun:test"
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Layer, ManagedRuntime } from "effect"
|
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import { watcherConfigLayer, withServices } from "../fixture/instance"
|
import { watcherConfigLayer, withServices } from "../fixture/instance"
|
||||||
import { FileWatcher, FileWatcherService } from "../../src/file/watcher"
|
import { FileWatcher } from "../../src/file/watcher"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { GlobalBus } from "../../src/bus/global"
|
import { GlobalBus } from "../../src/bus/global"
|
||||||
import { Vcs, VcsService } from "../../src/project/vcs"
|
import { Vcs } from "../../src/project/vcs"
|
||||||
|
|
||||||
// Skip in CI — native @parcel/watcher binding needed
|
// Skip in CI — native @parcel/watcher binding needed
|
||||||
const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
|
const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
|
||||||
@@ -19,15 +19,15 @@ const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe
|
|||||||
|
|
||||||
function withVcs(
|
function withVcs(
|
||||||
directory: string,
|
directory: string,
|
||||||
body: (rt: ManagedRuntime.ManagedRuntime<FileWatcherService | VcsService, never>) => Promise<void>,
|
body: (rt: ManagedRuntime.ManagedRuntime<FileWatcher.Service | Vcs.Service, never>) => Promise<void>,
|
||||||
) {
|
) {
|
||||||
return withServices(
|
return withServices(
|
||||||
directory,
|
directory,
|
||||||
Layer.merge(FileWatcherService.layer, VcsService.layer),
|
Layer.merge(FileWatcher.layer, Vcs.layer),
|
||||||
async (rt) => {
|
async (rt) => {
|
||||||
await rt.runPromise(FileWatcherService.use((s) => s.init()))
|
await rt.runPromise(FileWatcher.Service.use(() => Effect.void))
|
||||||
await rt.runPromise(VcsService.use((s) => s.init()))
|
await rt.runPromise(Vcs.Service.use(() => Effect.void))
|
||||||
await Bun.sleep(200)
|
await Bun.sleep(500)
|
||||||
await body(rt)
|
await body(rt)
|
||||||
},
|
},
|
||||||
{ provide: [watcherConfigLayer] },
|
{ provide: [watcherConfigLayer] },
|
||||||
@@ -36,10 +36,14 @@ function withVcs(
|
|||||||
|
|
||||||
type BranchEvent = { directory?: string; payload: { type: string; properties: { branch?: string } } }
|
type BranchEvent = { directory?: string; payload: { type: string; properties: { branch?: string } } }
|
||||||
|
|
||||||
/** Wait for a Vcs.Event.BranchUpdated event on GlobalBus */
|
/** Wait for a Vcs.Event.BranchUpdated event on GlobalBus, with retry polling as fallback */
|
||||||
function nextBranchUpdate(directory: string, timeout = 5000) {
|
function nextBranchUpdate(directory: string, timeout = 10_000) {
|
||||||
return new Promise<string | undefined>((resolve, reject) => {
|
return new Promise<string | undefined>((resolve, reject) => {
|
||||||
|
let settled = false
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
GlobalBus.off("event", on)
|
GlobalBus.off("event", on)
|
||||||
reject(new Error("timed out waiting for BranchUpdated event"))
|
reject(new Error("timed out waiting for BranchUpdated event"))
|
||||||
}, timeout)
|
}, timeout)
|
||||||
@@ -47,6 +51,8 @@ function nextBranchUpdate(directory: string, timeout = 5000) {
|
|||||||
function on(evt: BranchEvent) {
|
function on(evt: BranchEvent) {
|
||||||
if (evt.directory !== directory) return
|
if (evt.directory !== directory) return
|
||||||
if (evt.payload.type !== Vcs.Event.BranchUpdated.type) return
|
if (evt.payload.type !== Vcs.Event.BranchUpdated.type) return
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
GlobalBus.off("event", on)
|
GlobalBus.off("event", on)
|
||||||
resolve(evt.payload.properties.branch)
|
resolve(evt.payload.properties.branch)
|
||||||
@@ -67,7 +73,7 @@ describeVcs("Vcs", () => {
|
|||||||
await using tmp = await tmpdir({ git: true })
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
|
||||||
await withVcs(tmp.path, async (rt) => {
|
await withVcs(tmp.path, async (rt) => {
|
||||||
const branch = await rt.runPromise(VcsService.use((s) => s.branch()))
|
const branch = await rt.runPromise(Vcs.Service.use((s) => s.branch()))
|
||||||
expect(branch).toBeDefined()
|
expect(branch).toBeDefined()
|
||||||
expect(typeof branch).toBe("string")
|
expect(typeof branch).toBe("string")
|
||||||
})
|
})
|
||||||
@@ -77,7 +83,7 @@ describeVcs("Vcs", () => {
|
|||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
|
|
||||||
await withVcs(tmp.path, async (rt) => {
|
await withVcs(tmp.path, async (rt) => {
|
||||||
const branch = await rt.runPromise(VcsService.use((s) => s.branch()))
|
const branch = await rt.runPromise(Vcs.Service.use((s) => s.branch()))
|
||||||
expect(branch).toBeUndefined()
|
expect(branch).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -110,7 +116,7 @@ describeVcs("Vcs", () => {
|
|||||||
await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
|
await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
|
||||||
|
|
||||||
await pending
|
await pending
|
||||||
const current = await rt.runPromise(VcsService.use((s) => s.branch()))
|
const current = await rt.runPromise(Vcs.Service.use((s) => s.branch()))
|
||||||
expect(current).toBe(branch)
|
expect(current).toBe(branch)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ describe("project.initGit endpoint", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
|
await Instance.disposeAll()
|
||||||
reloadSpy.mockRestore()
|
reloadSpy.mockRestore()
|
||||||
GlobalBus.off("event", fn)
|
GlobalBus.off("event", fn)
|
||||||
}
|
}
|
||||||
@@ -111,7 +112,9 @@ describe("project.initGit endpoint", () => {
|
|||||||
vcs: "git",
|
vcs: "git",
|
||||||
worktree: tmp.path,
|
worktree: tmp.path,
|
||||||
})
|
})
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
|
await Instance.disposeAll()
|
||||||
reloadSpy.mockRestore()
|
reloadSpy.mockRestore()
|
||||||
GlobalBus.off("event", fn)
|
GlobalBus.off("event", fn)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, test, expect, beforeAll, afterAll } from "bun:test"
|
import { describe, test, expect, beforeAll, afterAll } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { DiscoveryService } from "../../src/skill/discovery"
|
import { Discovery } from "../../src/skill/discovery"
|
||||||
import { Global } from "../../src/global"
|
import { Global } from "../../src/global"
|
||||||
import { Filesystem } from "../../src/util/filesystem"
|
import { Filesystem } from "../../src/util/filesystem"
|
||||||
import { rm } from "fs/promises"
|
import { rm } from "fs/promises"
|
||||||
@@ -48,7 +48,7 @@ afterAll(async () => {
|
|||||||
|
|
||||||
describe("Discovery.pull", () => {
|
describe("Discovery.pull", () => {
|
||||||
const pull = (url: string) =>
|
const pull = (url: string) =>
|
||||||
Effect.runPromise(DiscoveryService.use((s) => s.pull(url)).pipe(Effect.provide(DiscoveryService.defaultLayer)))
|
Effect.runPromise(Discovery.Service.use((s) => s.pull(url)).pipe(Effect.provide(Discovery.defaultLayer)))
|
||||||
|
|
||||||
test("downloads skills from cloudflare url", async () => {
|
test("downloads skills from cloudflare url", async () => {
|
||||||
const dirs = await pull(CLOUDFLARE_SKILLS_URL)
|
const dirs = await pull(CLOUDFLARE_SKILLS_URL)
|
||||||
|
|||||||
@@ -1178,3 +1178,37 @@ test("diffFull with whitespace changes", async () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("revert with overlapping files across patches uses first patch hash", async () => {
|
||||||
|
await using tmp = await bootstrap()
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
// Write initial content and snapshot
|
||||||
|
await Filesystem.write(`${tmp.path}/shared.txt`, "v1")
|
||||||
|
const snap1 = await Snapshot.track()
|
||||||
|
expect(snap1).toBeTruthy()
|
||||||
|
|
||||||
|
// Modify and snapshot again
|
||||||
|
await Filesystem.write(`${tmp.path}/shared.txt`, "v2")
|
||||||
|
const snap2 = await Snapshot.track()
|
||||||
|
expect(snap2).toBeTruthy()
|
||||||
|
|
||||||
|
// Modify once more so both patches include shared.txt
|
||||||
|
await Filesystem.write(`${tmp.path}/shared.txt`, "v3")
|
||||||
|
|
||||||
|
const patch1 = await Snapshot.patch(snap1!)
|
||||||
|
const patch2 = await Snapshot.patch(snap2!)
|
||||||
|
|
||||||
|
// Both patches should include shared.txt
|
||||||
|
expect(patch1.files).toContain(fwd(tmp.path, "shared.txt"))
|
||||||
|
expect(patch2.files).toContain(fwd(tmp.path, "shared.txt"))
|
||||||
|
|
||||||
|
// Revert with patch1 first — should use snap1's hash (restoring "v1")
|
||||||
|
await Snapshot.revert([patch1, patch2])
|
||||||
|
|
||||||
|
const content = await fs.readFile(`${tmp.path}/shared.txt`, "utf-8")
|
||||||
|
expect(content).toBe("v1")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user