diff --git a/bun.lock b/bun.lock index 8d00483809..8daeb94a71 100644 --- a/bun.lock +++ b/bun.lock @@ -350,6 +350,7 @@ }, "devDependencies": { "@babel/core": "7.28.4", + "@effect/language-service": "0.78.0", "@octokit/webhooks-types": "7.6.1", "@opencode-ai/script": "workspace:*", "@parcel/watcher-darwin-arm64": "2.5.1", @@ -915,6 +916,8 @@ "@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="], + "@effect/language-service": ["@effect/language-service@0.78.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-dEYMhvhKFhikXpXjOlcByRKOU34xFeZbZcOuVTeWTQmjmZ/ZF4K3z0Ku6vL4cWKC8RRIB5di+ix18Y4f9ey5Dg=="], + "@emmetio/abbreviation": ["@emmetio/abbreviation@2.3.3", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA=="], "@emmetio/css-abbreviation": ["@emmetio/css-abbreviation@2.1.8", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index c1e2e88779..8dda9271da 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -27,6 +27,7 @@ }, "devDependencies": { "@babel/core": "7.28.4", + "@effect/language-service": "0.78.0", "@octokit/webhooks-types": "7.6.1", "@opencode-ai/script": "workspace:*", "@parcel/watcher-darwin-arm64": "2.5.1", diff --git a/packages/opencode/src/account/index.ts b/packages/opencode/src/account/index.ts index 1ea0f8a659..33315f985b 100644 --- a/packages/opencode/src/account/index.ts +++ b/packages/opencode/src/account/index.ts @@ -1,4 +1,4 @@ -import { Effect, ManagedRuntime, Option } from "effect" +import { Effect, Option } from "effect" import { Account as AccountSchema, @@ -13,7 +13,7 @@ import { export { AccessToken, AccountID, OrgID } from "./service" -const runtime = ManagedRuntime.make(AccountService.defaultLayer) +import { runtime } from "@/effect/runtime" type AccountServiceShape = ReturnType diff --git a/packages/opencode/src/account/schema.ts b/packages/opencode/src/account/schema.ts index 7a7eed0e6f..7417269207 100644 --- a/packages/opencode/src/account/schema.ts +++ b/packages/opencode/src/account/schema.ts @@ -42,10 +42,21 @@ export class Login extends Schema.Class("Login")({ interval: Schema.Number, }) {} -export type PollResult = - | { type: "success"; email: string } - | { type: "pending" } - | { type: "slow" } - | { type: "expired" } - | { type: "denied" } - | { type: "error"; msg: string } +export class PollSuccess extends Schema.TaggedClass()("PollSuccess", { + email: Schema.String, +}) {} + +export class PollPending extends Schema.TaggedClass()("PollPending", {}) {} + +export class PollSlow extends Schema.TaggedClass()("PollSlow", {}) {} + +export class PollExpired extends Schema.TaggedClass()("PollExpired", {}) {} + +export class PollDenied extends Schema.TaggedClass()("PollDenied", {}) {} + +export class PollError extends Schema.TaggedClass()("PollError", { + cause: Schema.Defect, +}) {} + +export const PollResult = Schema.Union([PollSuccess, PollPending, PollSlow, PollExpired, PollDenied, PollError]) +export type PollResult = Schema.Schema.Type diff --git a/packages/opencode/src/account/service.ts b/packages/opencode/src/account/service.ts index 2055bf7902..65c0f0eafc 100644 --- a/packages/opencode/src/account/service.ts +++ b/packages/opencode/src/account/service.ts @@ -9,9 +9,37 @@ import { import { withTransientReadRetry } from "@/util/effect-http-client" import { AccountRepo, type AccountRow } from "./repo" -import { AccessToken, Account, AccountID, AccountServiceError, Login, OrgID, type PollResult } from "./schema" +import { + AccessToken, + Account, + AccountID, + AccountServiceError, + Login, + OrgID, + PollDenied, + PollError, + PollExpired, + PollPending, + type PollResult, + PollSlow, + PollSuccess, +} from "./schema" -export { AccessToken, Account, AccountID, AccountServiceError, Login, OrgID, type PollResult } from "./schema" +export { + AccessToken, + Account, + AccountID, + AccountServiceError, + Login, + OrgID, + PollDenied, + PollError, + PollExpired, + PollPending, + type PollResult, + PollSlow, + PollSuccess, +} from "./schema" const RemoteOrg = Schema.Struct({ id: Schema.optional(OrgID), @@ -267,11 +295,11 @@ export class AccountService extends ServiceMap.Service< ) if (!parsed.access_token) { - if (parsed.error === "authorization_pending") return { type: "pending" } as const - if (parsed.error === "slow_down") return { type: "slow" } as const - if (parsed.error === "expired_token") return { type: "expired" } as const - if (parsed.error === "access_denied") return { type: "denied" } as const - return { type: "error", msg: parsed.error ?? JSON.stringify(parsed) } as const + if (parsed.error === "authorization_pending") return new PollPending() + if (parsed.error === "slow_down") return new PollSlow() + if (parsed.error === "expired_token") return new PollExpired() + if (parsed.error === "access_denied") return new PollDenied() + return new PollError({ cause: parsed.error }) } const access = parsed.access_token @@ -310,7 +338,7 @@ export class AccountService extends ServiceMap.Service< const userEmail = user.email if (!userId || !userEmail) { - return { type: "error", msg: "No id or email in response" } as const + return new PollError({ cause: "No id or email in response" }) } const firstOrgID = remoteOrgs.length > 0 ? Option.fromNullishOr(remoteOrgs[0].id) : Option.none() @@ -329,7 +357,7 @@ export class AccountService extends ServiceMap.Service< orgID: firstOrgID, }) - return { type: "success", email: userEmail } as const + return new PollSuccess({ email: userEmail }) }) return AccountService.of({ diff --git a/packages/opencode/src/cli/cmd/account.ts b/packages/opencode/src/cli/cmd/account.ts index 706d3710c6..b902e0db66 100644 --- a/packages/opencode/src/cli/cmd/account.ts +++ b/packages/opencode/src/cli/cmd/account.ts @@ -1,7 +1,111 @@ import { cmd } from "./cmd" -import * as prompts from "@clack/prompts" +import { Effect, Match, Option } from "effect" import { UI } from "../ui" -import { Account, OrgID } from "@/account" +import { OrgID } from "@/account" +import { runtime } from "@/effect/runtime" +import { AccountService, type PollResult } from "@/account/service" +import { type AccountServiceError } from "@/account/schema" +import * as Prompt from "../effect/prompt" +import open from "open" + +const openBrowser = (url: string) => Effect.promise(() => open(url).catch(() => undefined)) + +const println = (msg: string) => Effect.sync(() => UI.println(msg)) + +const loginEffect = Effect.fn("login")(function* (url?: string) { + const service = yield* AccountService + + yield* Prompt.intro("Log in") + const login = yield* service.login(url) + + yield* Prompt.log.info("Go to: " + login.url) + yield* Prompt.log.info("Enter code: " + login.user) + yield* openBrowser(login.url) + + const s = Prompt.spinner() + yield* s.start("Waiting for authorization...") + + const poll = (wait: number): Effect.Effect => + Effect.gen(function* () { + yield* Effect.sleep(wait) + const result = yield* service.poll(login) + if (result._tag === "PollPending") return yield* poll(wait) + if (result._tag === "PollSlow") return yield* poll(wait + 5000) + return result + }) + + const result = yield* poll(login.interval * 1000) + + yield* Match.valueTags(result, { + PollSuccess: (r) => + Effect.gen(function* () { + yield* s.stop("Logged in as " + r.email) + yield* Prompt.outro("Done") + }), + PollExpired: () => s.stop("Device code expired", 1), + PollDenied: () => s.stop("Authorization denied", 1), + PollError: (r) => s.stop("Error: " + String(r.cause), 1), + PollPending: () => s.stop("Unexpected state", 1), + PollSlow: () => s.stop("Unexpected state", 1), + }) +}) + +const logoutEffect = Effect.fn("logout")(function* (email?: string) { + const service = yield* AccountService + + if (email) { + const accounts = yield* service.list() + const match = accounts.find((a) => a.email === email) + if (!match) return yield* println("Account not found: " + email) + yield* service.remove(match.id) + yield* println("Logged out from " + email) + return + } + + const active = yield* service.active() + if (Option.isNone(active)) return yield* println("Not logged in") + yield* service.remove(active.value.id) + yield* println("Logged out from " + active.value.email) +}) + +const switchEffect = Effect.fn("switch")(function* () { + const service = yield* AccountService + + const active = yield* service.active() + if (Option.isNone(active)) return yield* println("Not logged in") + + const orgs = yield* service.orgs(active.value.id) + if (orgs.length === 0) return yield* println("No orgs found") + + yield* Prompt.intro("Switch org") + + const opts = orgs.map((o) => ({ + value: o.id, + label: o.id === active.value.org_id ? o.name + UI.Style.TEXT_DIM + " (active)" : o.name, + })) + + const selected = yield* Prompt.select({ message: "Select org", options: opts }) + yield* service.use(active.value.id, Option.some(OrgID.make(selected))) + yield* Prompt.outro("Switched to " + orgs.find((o) => o.id === selected)?.name) +}) + +const orgsEffect = Effect.fn("orgs")(function* () { + const service = yield* AccountService + + const accounts = yield* service.list() + if (accounts.length === 0) return yield* println("No accounts found") + + const allOrgs = yield* Effect.all( + accounts.map((account) => + service.orgs(account.id).pipe(Effect.map((orgs) => orgs.map((org) => ({ org, account })))), + ), + { concurrency: "unbounded" }, + ) + + for (const { org, account } of allOrgs.flat()) { + yield* println([org.name, account.email, org.id].join("\t")) + } +}) export const LoginCommand = cmd({ command: "login [url]", @@ -13,59 +117,7 @@ export const LoginCommand = cmd({ }), async handler(args) { UI.empty() - prompts.intro("Log in") - - const url = args.url as string | undefined - const login = await Account.login(url) - - prompts.log.info("Go to: " + login.url) - prompts.log.info("Enter code: " + login.user) - - try { - const open = - process.platform === "darwin" - ? ["open", login.url] - : process.platform === "win32" - ? ["cmd", "/c", "start", login.url] - : ["xdg-open", login.url] - Bun.spawn(open, { stdout: "ignore", stderr: "ignore" }) - } catch {} - - const spinner = prompts.spinner() - spinner.start("Waiting for authorization...") - - let wait = login.interval * 1000 - while (true) { - await Bun.sleep(wait) - - const result = await Account.poll(login) - - if (result.type === "success") { - spinner.stop("Logged in as " + result.email) - prompts.outro("Done") - return - } - - if (result.type === "pending") continue - - if (result.type === "slow") { - wait += 5000 - continue - } - - if (result.type === "expired") { - spinner.stop("Device code expired", 1) - return - } - - if (result.type === "denied") { - spinner.stop("Authorization denied", 1) - return - } - - spinner.stop("Error: " + result.msg, 1) - return - } + await runtime.runPromise(loginEffect(args.url)) }, }) @@ -78,27 +130,8 @@ export const LogoutCommand = cmd({ type: "string", }), async handler(args) { - const email = args.email as string | undefined - - if (email) { - const accounts = Account.list() - const match = accounts.find((a) => a.email === email) - if (!match) { - UI.println("Account not found: " + email) - return - } - Account.remove(match.id) - UI.println("Logged out from " + email) - return - } - - const active = Account.active() - if (!active) { - UI.println("Not logged in") - return - } - Account.remove(active.id) - UI.println("Logged out from " + active.email) + UI.empty() + await runtime.runPromise(logoutEffect(args.email)) }, }) @@ -107,35 +140,7 @@ export const SwitchCommand = cmd({ describe: "switch active org", async handler() { UI.empty() - - const active = Account.active() - if (!active) { - UI.println("Not logged in") - return - } - - const orgs = await Account.orgs(active.id) - if (orgs.length === 0) { - UI.println("No orgs found") - return - } - - prompts.intro("Switch org") - - const opts = orgs.map((o) => ({ - value: o.id, - label: o.id === active.org_id ? o.name + UI.Style.TEXT_DIM + " (active)" : o.name, - })) - - const selected = await prompts.select({ - message: "Select org", - options: opts, - }) - - if (prompts.isCancel(selected)) return - - Account.use(active.id, OrgID.make(selected)) - prompts.outro("Switched to " + orgs.find((o) => o.id === selected)?.name) + await runtime.runPromise(switchEffect()) }, }) @@ -144,18 +149,7 @@ export const OrgsCommand = cmd({ aliases: ["org"], describe: "list all orgs", async handler() { - const accounts = Account.list() - - if (accounts.length === 0) { - UI.println("No accounts found") - return - } - - for (const account of accounts) { - const orgs = await Account.orgs(account.id) - for (const org of orgs) { - UI.println([org.name, account.email, org.id].join("\t")) - } - } + UI.empty() + await runtime.runPromise(orgsEffect()) }, }) diff --git a/packages/opencode/src/cli/effect/prompt.ts b/packages/opencode/src/cli/effect/prompt.ts new file mode 100644 index 0000000000..dedc23f89c --- /dev/null +++ b/packages/opencode/src/cli/effect/prompt.ts @@ -0,0 +1,24 @@ +import * as prompts from "@clack/prompts" +import { Effect, Schema } from "effect" + +export class PromptCancelled extends Schema.TaggedErrorClass()("PromptCancelled", {}) {} + +export const intro = (msg: string) => Effect.sync(() => prompts.intro(msg)) +export const outro = (msg: string) => Effect.sync(() => prompts.outro(msg)) + +export const log = { + info: (msg: string) => Effect.sync(() => prompts.log.info(msg)), +} + +export const select = (opts: Parameters>[0]) => + Effect.tryPromise(() => prompts.select(opts)).pipe( + Effect.flatMap((result) => (prompts.isCancel(result) ? Effect.fail(new PromptCancelled()) : Effect.succeed(result))), + ) + +export const spinner = () => { + const s = prompts.spinner() + return { + start: (msg: string) => Effect.sync(() => s.start(msg)), + stop: (msg: string, code?: number) => Effect.sync(() => s.stop(msg, code)), + } +} diff --git a/packages/opencode/src/effect/runtime.ts b/packages/opencode/src/effect/runtime.ts new file mode 100644 index 0000000000..1868b38a01 --- /dev/null +++ b/packages/opencode/src/effect/runtime.ts @@ -0,0 +1,4 @@ +import { ManagedRuntime } from "effect" +import { AccountService } from "@/account/service" + +export const runtime = ManagedRuntime.make(AccountService.defaultLayer) diff --git a/packages/opencode/test/account/repo.test.ts b/packages/opencode/test/account/repo.test.ts index 2454c0b23a..92e3e52956 100644 --- a/packages/opencode/test/account/repo.test.ts +++ b/packages/opencode/test/account/repo.test.ts @@ -1,36 +1,36 @@ -import { test, expect, afterEach, afterAll } from "bun:test" -import { Effect, ManagedRuntime, Option } from "effect" +import { expect } from "bun:test" +import { Effect, Layer, Option } from "effect" import { AccountRepo } from "../../src/account/repo" import { AccountID, OrgID } from "../../src/account/schema" import { resetDatabase } from "../fixture/db" +import { testEffect } from "../fixture/effect" -afterEach(async () => { - await resetDatabase() -}) +const reset = Layer.effectDiscard(Effect.promise(() => resetDatabase())) -afterAll(async () => { - await runtime.dispose() -}) +const it = testEffect(Layer.merge(AccountRepo.layer, reset)) -const runtime = ManagedRuntime.make(AccountRepo.layer) -const run = (effect: Effect.Effect) => runtime.runPromise(effect) -const repo = AccountRepo +it.effect( + "list returns empty when no accounts exist", + Effect.gen(function* () { + const accounts = yield* AccountRepo.use((r) => r.list()) + expect(accounts).toEqual([]) + }), +) -test("list returns empty when no accounts exist", async () => { - const accounts = await run(repo.use((r) => r.list())) - expect(accounts).toEqual([]) -}) +it.effect( + "active returns none when no accounts exist", + Effect.gen(function* () { + const active = yield* AccountRepo.use((r) => r.active()) + expect(Option.isNone(active)).toBe(true) + }), +) -test("active returns none when no accounts exist", async () => { - const active = await run(repo.use((r) => r.active())) - expect(Option.isNone(active)).toBe(true) -}) - -test("persistAccount inserts and getRow retrieves", async () => { - const id = AccountID.make("user-1") - await run( - repo.use((r) => +it.effect( + "persistAccount inserts and getRow retrieves", + Effect.gen(function* () { + const id = AccountID.make("user-1") + yield* AccountRepo.use((r) => r.persistAccount({ id, email: "test@example.com", @@ -40,23 +40,24 @@ test("persistAccount inserts and getRow retrieves", async () => { expiry: Date.now() + 3600_000, orgID: Option.some(OrgID.make("org-1")), }), - ), - ) + ) - const row = await run(repo.use((r) => r.getRow(id))) - expect(Option.isSome(row)).toBe(true) - const value = Option.getOrThrow(row) - expect(value.id).toBe("user-1") - expect(value.email).toBe("test@example.com") - expect(value.org_id).toBe("org-1") -}) + const row = yield* AccountRepo.use((r) => r.getRow(id)) + expect(Option.isSome(row)).toBe(true) + const value = Option.getOrThrow(row) + expect(value.id).toBe("user-1") + expect(value.email).toBe("test@example.com") + expect(value.org_id).toBe("org-1") + }), +) -test("persistAccount sets active account (clears previous org_ids)", async () => { - const id1 = AccountID.make("user-1") - const id2 = AccountID.make("user-2") +it.effect( + "persistAccount sets active account (clears previous org_ids)", + Effect.gen(function* () { + const id1 = AccountID.make("user-1") + const id2 = AccountID.make("user-2") - await run( - repo.use((r) => + yield* AccountRepo.use((r) => r.persistAccount({ id: id1, email: "first@example.com", @@ -66,11 +67,9 @@ test("persistAccount sets active account (clears previous org_ids)", async () => expiry: Date.now() + 3600_000, orgID: Option.some(OrgID.make("org-1")), }), - ), - ) + ) - await run( - repo.use((r) => + yield* AccountRepo.use((r) => r.persistAccount({ id: id2, email: "second@example.com", @@ -80,25 +79,24 @@ test("persistAccount sets active account (clears previous org_ids)", async () => expiry: Date.now() + 3600_000, orgID: Option.some(OrgID.make("org-2")), }), - ), - ) + ) - // First account should have org_id cleared - const row1 = await run(repo.use((r) => r.getRow(id1))) - expect(Option.getOrThrow(row1).org_id).toBeNull() + const row1 = yield* AccountRepo.use((r) => r.getRow(id1)) + expect(Option.getOrThrow(row1).org_id).toBeNull() - // Second account should be active - const active = await run(repo.use((r) => r.active())) - expect(Option.isSome(active)).toBe(true) - expect(Option.getOrThrow(active).id).toBe(AccountID.make("user-2")) -}) + const active = yield* AccountRepo.use((r) => r.active()) + expect(Option.isSome(active)).toBe(true) + expect(Option.getOrThrow(active).id).toBe(AccountID.make("user-2")) + }), +) -test("list returns all accounts", async () => { - const id1 = AccountID.make("user-1") - const id2 = AccountID.make("user-2") +it.effect( + "list returns all accounts", + Effect.gen(function* () { + const id1 = AccountID.make("user-1") + const id2 = AccountID.make("user-2") - await run( - repo.use((r) => + yield* AccountRepo.use((r) => r.persistAccount({ id: id1, email: "a@example.com", @@ -108,11 +106,9 @@ test("list returns all accounts", async () => { expiry: Date.now() + 3600_000, orgID: Option.none(), }), - ), - ) + ) - await run( - repo.use((r) => + yield* AccountRepo.use((r) => r.persistAccount({ id: id2, email: "b@example.com", @@ -122,19 +118,20 @@ test("list returns all accounts", async () => { expiry: Date.now() + 3600_000, orgID: Option.some(OrgID.make("org-1")), }), - ), - ) + ) - const accounts = await run(repo.use((r) => r.list())) - expect(accounts.length).toBe(2) - expect(accounts.map((a) => a.email).sort()).toEqual(["a@example.com", "b@example.com"]) -}) + const accounts = yield* AccountRepo.use((r) => r.list()) + expect(accounts.length).toBe(2) + expect(accounts.map((a) => a.email).sort()).toEqual(["a@example.com", "b@example.com"]) + }), +) -test("remove deletes an account", async () => { - const id = AccountID.make("user-1") +it.effect( + "remove deletes an account", + Effect.gen(function* () { + const id = AccountID.make("user-1") - await run( - repo.use((r) => + yield* AccountRepo.use((r) => r.persistAccount({ id, email: "test@example.com", @@ -144,20 +141,21 @@ test("remove deletes an account", async () => { expiry: Date.now() + 3600_000, orgID: Option.none(), }), - ), - ) + ) - await run(repo.use((r) => r.remove(id))) + yield* AccountRepo.use((r) => r.remove(id)) - const row = await run(repo.use((r) => r.getRow(id))) - expect(Option.isNone(row)).toBe(true) -}) + const row = yield* AccountRepo.use((r) => r.getRow(id)) + expect(Option.isNone(row)).toBe(true) + }), +) -test("use sets org_id on account", async () => { - const id = AccountID.make("user-1") +it.effect( + "use sets org_id on account", + Effect.gen(function* () { + const id = AccountID.make("user-1") - await run( - repo.use((r) => + yield* AccountRepo.use((r) => r.persistAccount({ id, email: "test@example.com", @@ -167,25 +165,24 @@ test("use sets org_id on account", async () => { expiry: Date.now() + 3600_000, orgID: Option.none(), }), - ), - ) + ) - // Set org - await run(repo.use((r) => r.use(id, Option.some(OrgID.make("org-99"))))) - const row = await run(repo.use((r) => r.getRow(id))) - expect(Option.getOrThrow(row).org_id).toBe("org-99") + yield* AccountRepo.use((r) => r.use(id, Option.some(OrgID.make("org-99")))) + const row = yield* AccountRepo.use((r) => r.getRow(id)) + expect(Option.getOrThrow(row).org_id).toBe("org-99") - // Clear org - await run(repo.use((r) => r.use(id, Option.none()))) - const row2 = await run(repo.use((r) => r.getRow(id))) - expect(Option.getOrThrow(row2).org_id).toBeNull() -}) + yield* AccountRepo.use((r) => r.use(id, Option.none())) + const row2 = yield* AccountRepo.use((r) => r.getRow(id)) + expect(Option.getOrThrow(row2).org_id).toBeNull() + }), +) -test("persistToken updates token fields", async () => { - const id = AccountID.make("user-1") +it.effect( + "persistToken updates token fields", + Effect.gen(function* () { + const id = AccountID.make("user-1") - await run( - repo.use((r) => + yield* AccountRepo.use((r) => r.persistAccount({ id, email: "test@example.com", @@ -195,24 +192,32 @@ test("persistToken updates token fields", async () => { expiry: 1000, orgID: Option.none(), }), - ), - ) + ) - const newExpiry = Date.now() + 7200_000 - await run(repo.use((r) => r.persistToken({ accountID: id, accessToken: "new_token", refreshToken: "new_refresh", expiry: Option.some(newExpiry) }))) + const expiry = Date.now() + 7200_000 + yield* AccountRepo.use((r) => + r.persistToken({ + accountID: id, + accessToken: "new_token", + refreshToken: "new_refresh", + expiry: Option.some(expiry), + }), + ) - const row = await run(repo.use((r) => r.getRow(id))) - const value = Option.getOrThrow(row) - expect(value.access_token).toBe("new_token") - expect(value.refresh_token).toBe("new_refresh") - expect(value.token_expiry).toBe(newExpiry) -}) + const row = yield* AccountRepo.use((r) => r.getRow(id)) + const value = Option.getOrThrow(row) + expect(value.access_token).toBe("new_token") + expect(value.refresh_token).toBe("new_refresh") + expect(value.token_expiry).toBe(expiry) + }), +) -test("persistToken with no expiry sets token_expiry to null", async () => { - const id = AccountID.make("user-1") +it.effect( + "persistToken with no expiry sets token_expiry to null", + Effect.gen(function* () { + const id = AccountID.make("user-1") - await run( - repo.use((r) => + yield* AccountRepo.use((r) => r.persistAccount({ id, email: "test@example.com", @@ -222,20 +227,28 @@ test("persistToken with no expiry sets token_expiry to null", async () => { expiry: 1000, orgID: Option.none(), }), - ), - ) + ) - await run(repo.use((r) => r.persistToken({ accountID: id, accessToken: "new_token", refreshToken: "new_refresh", expiry: Option.none() }))) + yield* AccountRepo.use((r) => + r.persistToken({ + accountID: id, + accessToken: "new_token", + refreshToken: "new_refresh", + expiry: Option.none(), + }), + ) - const row = await run(repo.use((r) => r.getRow(id))) - expect(Option.getOrThrow(row).token_expiry).toBeNull() -}) + const row = yield* AccountRepo.use((r) => r.getRow(id)) + expect(Option.getOrThrow(row).token_expiry).toBeNull() + }), +) -test("persistAccount upserts on conflict", async () => { - const id = AccountID.make("user-1") +it.effect( + "persistAccount upserts on conflict", + Effect.gen(function* () { + const id = AccountID.make("user-1") - await run( - repo.use((r) => + yield* AccountRepo.use((r) => r.persistAccount({ id, email: "test@example.com", @@ -245,12 +258,9 @@ test("persistAccount upserts on conflict", async () => { expiry: 1000, orgID: Option.some(OrgID.make("org-1")), }), - ), - ) + ) - // Upsert same id with new tokens - await run( - repo.use((r) => + yield* AccountRepo.use((r) => r.persistAccount({ id, email: "test@example.com", @@ -260,19 +270,22 @@ test("persistAccount upserts on conflict", async () => { expiry: 2000, orgID: Option.some(OrgID.make("org-2")), }), - ), - ) + ) - const accounts = await run(repo.use((r) => r.list())) - expect(accounts.length).toBe(1) + const accounts = yield* AccountRepo.use((r) => r.list()) + expect(accounts.length).toBe(1) - const row = await run(repo.use((r) => r.getRow(id))) - const value = Option.getOrThrow(row) - expect(value.access_token).toBe("at_v2") - expect(value.org_id).toBe("org-2") -}) + const row = yield* AccountRepo.use((r) => r.getRow(id)) + const value = Option.getOrThrow(row) + expect(value.access_token).toBe("at_v2") + expect(value.org_id).toBe("org-2") + }), +) -test("getRow returns none for nonexistent account", async () => { - const row = await run(repo.use((r) => r.getRow(AccountID.make("nope")))) - expect(Option.isNone(row)).toBe(true) -}) +it.effect( + "getRow returns none for nonexistent account", + Effect.gen(function* () { + const row = yield* AccountRepo.use((r) => r.getRow(AccountID.make("nope"))) + expect(Option.isNone(row)).toBe(true) + }), +) diff --git a/packages/opencode/test/fixture/effect.ts b/packages/opencode/test/fixture/effect.ts new file mode 100644 index 0000000000..b75610139f --- /dev/null +++ b/packages/opencode/test/fixture/effect.ts @@ -0,0 +1,7 @@ +import { test } from "bun:test" +import { Effect, Layer } from "effect" + +export const testEffect = (layer: Layer.Layer) => ({ + effect: (name: string, value: Effect.Effect) => + test(name, () => Effect.runPromise(value.pipe(Effect.provide(layer)))), +}) diff --git a/packages/opencode/tsconfig.json b/packages/opencode/tsconfig.json index 9067d84fd6..44335bd80d 100644 --- a/packages/opencode/tsconfig.json +++ b/packages/opencode/tsconfig.json @@ -11,6 +11,11 @@ "paths": { "@/*": ["./src/*"], "@tui/*": ["./src/cli/cmd/tui/*"] - } + }, + "plugins": [{ + "name": "@effect/language-service", + "transform": "@effect/language-service/transform", + "namespaceImportPackages": ["effect", "@effect/*"] + }] } }