mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-26 15:55:45 +00:00
core: effectify CLI account commands with tagged PollResult union
- Rewrite all account CLI commands (login, logout, switch, orgs) as Effect.fn with AccountService accessed directly via yield* - Convert PollResult from plain object union to Schema.TaggedClass variants with Schema.Union and Match.valueTags for exhaustive matching - Add recursive poll function for device auth flow (stack-safe via Effect trampolining) - Add shared Effect runtime at src/effect/runtime.ts - Add Effect wrappers for @clack/prompts at src/cli/effect/prompt.ts - Add @effect/language-service TS plugin for editor support - Refactor repo tests to use testEffect helper with Layer-based setup - Parallelize org fetching in orgs command Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
3
bun.lock
3
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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<typeof AccountService.of>
|
||||
|
||||
|
||||
@@ -42,10 +42,21 @@ export class Login extends Schema.Class<Login>("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>()("PollSuccess", {
|
||||
email: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class PollPending extends Schema.TaggedClass<PollPending>()("PollPending", {}) {}
|
||||
|
||||
export class PollSlow extends Schema.TaggedClass<PollSlow>()("PollSlow", {}) {}
|
||||
|
||||
export class PollExpired extends Schema.TaggedClass<PollExpired>()("PollExpired", {}) {}
|
||||
|
||||
export class PollDenied extends Schema.TaggedClass<PollDenied>()("PollDenied", {}) {}
|
||||
|
||||
export class PollError extends Schema.TaggedClass<PollError>()("PollError", {
|
||||
cause: Schema.Defect,
|
||||
}) {}
|
||||
|
||||
export const PollResult = Schema.Union([PollSuccess, PollPending, PollSlow, PollExpired, PollDenied, PollError])
|
||||
export type PollResult = Schema.Schema.Type<typeof PollResult>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<PollResult, AccountServiceError> =>
|
||||
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())
|
||||
},
|
||||
})
|
||||
|
||||
24
packages/opencode/src/cli/effect/prompt.ts
Normal file
24
packages/opencode/src/cli/effect/prompt.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { Effect, Schema } from "effect"
|
||||
|
||||
export class PromptCancelled extends Schema.TaggedErrorClass<PromptCancelled>()("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 = <Value>(opts: Parameters<typeof prompts.select<Value>>[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)),
|
||||
}
|
||||
}
|
||||
4
packages/opencode/src/effect/runtime.ts
Normal file
4
packages/opencode/src/effect/runtime.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { ManagedRuntime } from "effect"
|
||||
import { AccountService } from "@/account/service"
|
||||
|
||||
export const runtime = ManagedRuntime.make(AccountService.defaultLayer)
|
||||
@@ -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 = <A>(effect: Effect.Effect<A, unknown, AccountRepo>) => 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)
|
||||
}),
|
||||
)
|
||||
|
||||
7
packages/opencode/test/fixture/effect.ts
Normal file
7
packages/opencode/test/fixture/effect.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { test } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
|
||||
export const testEffect = <R, E>(layer: Layer.Layer<R, E, never>) => ({
|
||||
effect: <A, E2>(name: string, value: Effect.Effect<A, E2, R>) =>
|
||||
test(name, () => Effect.runPromise(value.pipe(Effect.provide(layer)))),
|
||||
})
|
||||
@@ -11,6 +11,11 @@
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@tui/*": ["./src/cli/cmd/tui/*"]
|
||||
}
|
||||
},
|
||||
"plugins": [{
|
||||
"name": "@effect/language-service",
|
||||
"transform": "@effect/language-service/transform",
|
||||
"namespaceImportPackages": ["effect", "@effect/*"]
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user