1/**
2 * CursorKeychain - the Cursor CLI's macOS Keychain login, shared by usage
3 * history and usage limits in one server process.
4 *
5 * macOS shows the access prompt on the server's own screen, which a remote
6 * client cannot answer, so a read gives up after a timeout. The Keychain read
7 * stays in flight: the next read joins it instead of stacking a second prompt,
8 * and picks up the token once someone allows access.
9 *
10 * @module provider-cursor/server/CursorKeychain
11 */
12import * as NodeModule from "node:module";
13
14import * as Clock from "effect/Clock";
15import * as Context from "effect/Context";
16import * as Deferred from "effect/Deferred";
17import * as Duration from "effect/Duration";
18import * as Effect from "effect/Effect";
19import * as Layer from "effect/Layer";
20import * as Option from "effect/Option";
21import * as Schema from "effect/Schema";
22
23const CACHE_MS = 5 * 60_000;
24const DEFAULT_TIMEOUT = Duration.seconds(30);
25
26const requireForKeyring = NodeModule.createRequire(import.meta.url);
27
28/** Nobody answered the macOS Keychain prompt in time. */
29export class CursorKeychainTimeoutError extends Schema.TaggedError<CursorKeychainTimeoutError>()(
30 "CursorKeychainTimeoutError",
31 {},
32) {
33 override get message(): string {
34 return "Timed out waiting for Keychain access.";
35 }
36}
37
38export const isCursorKeychainTimeoutError = Schema.is(CursorKeychainTimeoutError);
39
40/** The Keychain could not be read. */
41export class CursorKeychainReadError extends Schema.TaggedError<CursorKeychainReadError>()(
42 "CursorKeychainReadError",
43 { cause: Schema.Defect() },
44) {
45 override get message(): string {
46 return "Cursor Keychain credentials could not be read.";
47 }
48}
49
50export class CursorKeychain extends Context.Service<
51 CursorKeychain,
52 {
53 /** The access token, or `null` when the Keychain holds no Cursor login. */
54 readonly accessToken: Effect.Effect<
55 string | null,
56 CursorKeychainTimeoutError | CursorKeychainReadError
57 >;
58 }
59>()("@t3tools/provider-cursor/server/CursorKeychain") {}
60
61/** Reads the Cursor CLI's default macOS credential without invoking the shared security binary. */
62const readKeychainEntry = Effect.tryPromise({
63 try: async () => {
64 const { AsyncEntry } = requireForKeyring(
65 "@napi-rs/keyring",
66 ) as typeof import("@napi-rs/keyring");
67 return (await new AsyncEntry("cursor-access-token", "cursor-user").getPassword()) ?? null;
68 },
69 catch: (cause) => new CursorKeychainReadError({ cause }),
70});
71
72/** A Keychain backed by `read`, which tests replace. */
73export const make = (
74 read: Effect.Effect<string | null, CursorKeychainReadError> = readKeychainEntry,
75 timeout: Duration.Input = DEFAULT_TIMEOUT,
76): CursorKeychain["Service"] => {
77 let cached: { readonly token: string; readonly untilMs: number } | null = null;
78 let pending: Deferred.Deferred<string | null, CursorKeychainReadError> | null = null;
79
80 /** Joins the read in flight, or starts one that outlives the caller. */
81 const startRead = Effect.uninterruptible(
82 Effect.gen(function* () {
83 if (pending !== null) return pending;
84 const done = Deferred.makeUnsafe<string | null, CursorKeychainReadError>();
85 pending = done;
86 yield* read.pipe(
87 Effect.tap((token) =>
88 Clock.currentTimeMillis.pipe(
89 Effect.map((nowMs) => {
90 cached = token ? { token, untilMs: nowMs + CACHE_MS } : null;
91 }),
92 ),
93 ),
94 Effect.onExit((exit) =>
95 Effect.suspend(() => {
96 pending = null;
97 return Deferred.done(done, exit);
98 }),
99 ),
100 Effect.forkDetach,
101 );
102 return done;
103 }),
104 );
105
106 const accessToken = Effect.gen(function* () {
107 const nowMs = yield* Clock.currentTimeMillis;
108 if (cached !== null && cached.untilMs > nowMs) return cached.token;
109 const done = yield* startRead;
110 const token = yield* Deferred.await(done).pipe(Effect.timeoutOption(timeout));
111 if (Option.isNone(token)) return yield* new CursorKeychainTimeoutError();
112 return token.value;
113 });
114
115 return CursorKeychain.of({ accessToken });
116};
117
118export const layer = Layer.sync(CursorKeychain, () => make());