import type { CommandExecutor } from "./tod.js"; import { runTod } from "./tod.js"; export type OneDevContextStatus = | "ready" | "not_configured" | "no_project" | "auth_failed" | "unavailable"; export interface OneDevSessionContext { readonly cwd: string; readonly status: OneDevContextStatus; readonly serverUrl?: string; /** Project path inferred from the repository's git remotes. */ readonly project?: string; readonly loginName?: string; readonly problem?: string; } export interface ContextRefreshOptions { verifyAuth?: boolean; force?: boolean; signal?: AbortSignal; } export const TOD_INSTALL_HINT = "tod is not installed. Install it (curl -fsSL https://code.onedev.io/onedev/tod/~raw/main/install.sh | bash), then run `tod config set`."; function failed( cwd: string, status: Exclude, problem: string, serverUrl?: string, project?: string, ): OneDevSessionContext { return { cwd, status, problem, serverUrl, project }; } async function authenticateContext( exec: CommandExecutor, context: OneDevSessionContext, signal?: AbortSignal, ): Promise { if ( context.status !== "ready" || !context.serverUrl || !context.project ) { return context; } try { const login = await runTod(exec, ["get-login-name"], { cwd: context.cwd, timeoutMs: 15_000, signal, maxOutputBytes: 2_000, }); return { ...context, loginName: login.text.trim() || undefined }; } catch (cause) { return failed( context.cwd, "auth_failed", `OneDev rejected the server or access token (${cause instanceof Error ? cause.message : String(cause)}). Run \`tod config set\` to fix credentials.`, context.serverUrl, context.project, ); } } /** Probe local configuration/project first; authenticate only on explicit use. */ export async function probeOneDevContext( exec: CommandExecutor, cwd: string, signal?: AbortSignal, verifyAuth = false, ): Promise { let serverUrl: string; try { const configured = await runTod(exec, ["config", "get", "server-url"], { cwd, timeoutMs: 10_000, signal, maxOutputBytes: 2_000, }); serverUrl = configured.text.trim(); } catch (cause) { const message = cause instanceof Error ? cause.message : String(cause); return failed( cwd, "unavailable", message.startsWith("tod is not usable") ? TOD_INSTALL_HINT : message, ); } if (!serverUrl) { return failed( cwd, "not_configured", "tod is installed but not configured. Run `tod config set` for server-url and access-token, or set ONEDEV_SERVER_URL and ONEDEV_ACCESS_TOKEN.", ); } let project: string; try { const current = await runTod(exec, ["project", "current"], { cwd, timeoutMs: 10_000, signal, maxOutputBytes: 2_000, }); project = current.text.trim(); } catch (cause) { return failed( cwd, "no_project", `No OneDev project can be inferred from this repository's remotes (${cause instanceof Error ? cause.message : String(cause)}). Run Pi from a OneDev clone or add a OneDev remote.`, serverUrl, ); } if (!project) { return failed( cwd, "no_project", "No OneDev project can be inferred from this repository's remotes. Run Pi from a OneDev clone or add a OneDev remote.", serverUrl, ); } if (!verifyAuth) return { cwd, status: "ready", serverUrl, project }; return authenticateContext( exec, { cwd, status: "ready", serverUrl, project }, signal, ); } function waitForCaller(promise: Promise, signal?: AbortSignal): Promise { if (!signal) return promise; if (signal.aborted) return Promise.reject( signal.reason instanceof Error ? signal.reason : new Error("cancelled"), ); return new Promise((resolve, reject) => { const abort = (): void => reject( signal.reason instanceof Error ? signal.reason : new Error("cancelled"), ); signal.addEventListener("abort", abort, { once: true }); promise.then( (value) => { signal.removeEventListener("abort", abort); resolve(value); }, (error) => { signal.removeEventListener("abort", abort); reject(error); }, ); }); } /** Coalesces probes while isolating individual caller cancellation. */ export class OneDevContextManager { #cwd: string | undefined; #current: OneDevSessionContext | undefined; #pending: | { controller: AbortController; promise: Promise; } | undefined; constructor(readonly exec: CommandExecutor) {} current(): OneDevSessionContext { if (!this.#current) throw new Error("OneDev extension is still initializing"); return this.#current; } reset(cwd: string): void { this.#pending?.controller.abort(new Error("OneDev session context reset")); this.#pending = undefined; this.#current = undefined; this.#cwd = cwd; } clear(): void { this.#pending?.controller.abort(new Error("OneDev session ended")); this.#pending = undefined; this.#current = undefined; this.#cwd = undefined; } async refresh( options: ContextRefreshOptions = {}, ): Promise { if (!this.#cwd) throw new Error("OneDev extension is still initializing"); const verifyAuth = options.verifyAuth === true; if ( !options.force && this.#current && (!verifyAuth || this.#current.loginName) ) { return this.#current; } if (this.#pending) { const context = await waitForCaller(this.#pending.promise, options.signal); if (!verifyAuth || context.loginName || context.status !== "ready") { return context; } } const controller = new AbortController(); const cached = this.#current; const canAuthenticateCached = verifyAuth && !options.force && cached?.status === "ready" && !cached.loginName; const promise = (canAuthenticateCached ? authenticateContext(this.exec, cached, controller.signal) : probeOneDevContext( this.exec, this.#cwd, controller.signal, verifyAuth, )).then((context) => { if (!controller.signal.aborted) this.#current = context; return context; }); const pending = { controller, promise }; this.#pending = pending; const clearPending = (): void => { if (this.#pending === pending) this.#pending = undefined; }; void promise.then(clearPending, clearPending); return waitForCaller(promise, options.signal); } } export function formatContext(context: OneDevSessionContext): string { if (context.status !== "ready") { return `OneDev ${context.status.replace("_", " ")}: ${context.problem ?? "unknown problem"}`; } const parts = [`server ${context.serverUrl}`, `project ${context.project}`]; parts.push(context.loginName ? `user ${context.loginName}` : "auth not checked yet"); return `OneDev ready: ${parts.join(" ยท ")}`; } export function setupGuidance(context: OneDevSessionContext): string { switch (context.status) { case "ready": return `${formatContext(context)}\nNo setup action is required.`; case "unavailable": case "not_configured": case "no_project": case "auth_failed": return `${formatContext(context)}\n${context.problem ?? TOD_INSTALL_HINT}`; default: throw new Error(`unsupported OneDev context status: ${String(context.status)}`); } }