diff --git a/packages/cli/src/auth/status-report.ts b/packages/cli/src/auth/status-report.ts new file mode 100644 index 0000000..4fa1626 --- /dev/null +++ b/packages/cli/src/auth/status-report.ts @@ -0,0 +1,108 @@ +import type { ActiveSession, AuthStatus } from "@wiseiodev/linear-core"; + +export interface AuthStatusUser { + readonly id: string; + readonly name: string; + readonly email: string; +} + +export interface AuthStatusWorkspace { + readonly id: string; + readonly name: string; + readonly urlKey: string; +} + +export interface AuthStatusTeam { + readonly id: string; + readonly key: string; + readonly name: string; +} + +export interface AuthStatusReport extends AuthStatus { + readonly user: AuthStatusUser | null; + readonly workspace: AuthStatusWorkspace | null; + readonly defaultTeam: AuthStatusTeam | null; +} + +export interface AuthStatusReportManager { + status(profile?: string): Promise; + openSession(options?: { readonly profile?: string }): Promise; + getProfile(profile?: string): Promise<{ readonly team?: string } | undefined>; +} + +async function fetchUser(session: ActiveSession): Promise { + try { + const viewer = await session.client.viewer; + return { + id: viewer.id, + name: viewer.displayName ?? viewer.name, + email: viewer.email, + }; + } catch { + return null; + } +} + +async function fetchWorkspace(session: ActiveSession): Promise { + try { + const organization = await session.client.organization; + return { + id: organization.id, + name: organization.name, + urlKey: organization.urlKey, + }; + } catch { + return null; + } +} + +async function fetchDefaultTeam( + session: ActiveSession, + teamKey: string | undefined, +): Promise { + if (!teamKey) { + return null; + } + + try { + let cursor: string | undefined; + do { + const page = await session.gateway.listTeams({ limit: 250, cursor }); + const found = page.items.find((team) => team.key === teamKey); + if (found) { + return { id: found.id, key: found.key, name: found.name }; + } + cursor = page.nextCursor ?? undefined; + } while (cursor); + } catch {} + + return null; +} + +export async function buildAuthStatusReport( + authManager: AuthStatusReportManager, + profile?: string, +): Promise { + const status = await authManager.status(profile); + const resolvedProfile = status.profile; + + if (!status.hasAccessToken && !status.hasApiKey) { + return { ...status, user: null, workspace: null, defaultTeam: null }; + } + + let session: ActiveSession; + try { + session = await authManager.openSession({ profile: resolvedProfile }); + } catch { + return { ...status, user: null, workspace: null, defaultTeam: null }; + } + + const profileConfig = await authManager.getProfile(resolvedProfile); + const [user, workspace, defaultTeam] = await Promise.all([ + fetchUser(session), + fetchWorkspace(session), + fetchDefaultTeam(session, profileConfig?.team), + ]); + + return { ...status, user, workspace, defaultTeam }; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index cde47c2..ed7af70 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -48,6 +48,7 @@ import { runLinearTui } from "@wiseiodev/tui"; import { Command } from "commander"; import open from "open"; import { runInteractiveOAuthLogin } from "./auth/login.js"; +import { buildAuthStatusReport } from "./auth/status-report.js"; import { isIssueUpdateInput } from "./commands/issue-guards.js"; import { registerIssuesBulkUpdate } from "./commands/issues-bulk-update.js"; import { registerResourceCommand } from "./commands/resource.js"; @@ -386,8 +387,8 @@ export function createProgram(authManager = new AuthManager()): Command { .action(async (_, cmd) => { const globals = getGlobalOptions(cmd); try { - const status = await authManager.status(globals.profile); - renderEnvelope(successEnvelope("auth", "status", status), globals); + const report = await buildAuthStatusReport(authManager, globals.profile); + renderEnvelope(successEnvelope("auth", "status", report), globals); } catch (error) { const normalized = normalizeError(error); renderEnvelope( diff --git a/packages/cli/tests/auth-status.test.ts b/packages/cli/tests/auth-status.test.ts new file mode 100644 index 0000000..e07f483 --- /dev/null +++ b/packages/cli/tests/auth-status.test.ts @@ -0,0 +1,222 @@ +import type { ActiveSession, AuthStatus } from "@wiseiodev/linear-core"; +import { describe, expect, test, vi } from "vitest"; +import { type AuthStatusReportManager, buildAuthStatusReport } from "../src/auth/status-report.js"; + +const baseAuthenticatedStatus: AuthStatus = { + profile: "default", + method: "oauth", + hasApiKey: false, + hasAccessToken: true, + oauthConfigured: true, + hasRefreshToken: true, + expiresAt: "2099-01-01T00:00:00.000Z", + expired: false, + scopes: ["read", "write"], + redirectUri: "http://127.0.0.1:8787/oauth/callback", +}; + +const unauthenticatedStatus: AuthStatus = { + profile: "default", + hasApiKey: false, + hasAccessToken: false, + oauthConfigured: false, + hasRefreshToken: false, + expired: false, +}; + +interface SessionStub { + readonly viewer?: { id: string; name: string; displayName?: string; email: string } | Error; + readonly organization?: { id: string; name: string; urlKey: string } | Error; + readonly listTeams?: + | ReadonlyArray<{ id: string; key: string; name: string }> + | ReadonlyArray<{ + readonly items: ReadonlyArray<{ id: string; key: string; name: string }>; + readonly nextCursor: string | null; + }>; +} + +function makeSession(stub: SessionStub): ActiveSession { + const viewer = + stub.viewer instanceof Error ? Promise.reject(stub.viewer) : Promise.resolve(stub.viewer); + const organization = + stub.organization instanceof Error + ? Promise.reject(stub.organization) + : Promise.resolve(stub.organization); + + let listTeamsCallCount = 0; + const gateway = { + async listTeams() { + const configured = stub.listTeams ?? []; + const page = configured[listTeamsCallCount]; + listTeamsCallCount += 1; + + if (page && typeof page === "object" && "items" in page && Array.isArray(page.items)) { + return page; + } + + return { items: configured, nextCursor: null }; + }, + }; + + return { + profile: "default", + client: { viewer, organization } as unknown as ActiveSession["client"], + gateway: gateway as unknown as ActiveSession["gateway"], + credentials: { accessToken: "x" }, + }; +} + +function makeManager(overrides: { + status: AuthStatus; + session?: ActiveSession | Error; + profileTeam?: string; + profileMissing?: boolean; +}): AuthStatusReportManager { + return { + async status() { + return overrides.status; + }, + async openSession() { + if (overrides.session instanceof Error) throw overrides.session; + if (!overrides.session) throw new Error("no session configured"); + return overrides.session; + }, + async getProfile() { + if (overrides.profileMissing) return undefined; + return overrides.profileTeam ? { team: overrides.profileTeam } : {}; + }, + }; +} + +describe("buildAuthStatusReport", () => { + test("authenticated profile with team resolved by configured key", async () => { + const session = makeSession({ + viewer: { id: "u1", name: "Dan", displayName: "Daniel", email: "dan@example.com" }, + organization: { id: "org-1", name: "Acme", urlKey: "acme" }, + listTeams: [{ id: "t1", key: "ENG", name: "Engineering" }], + }); + const manager = makeManager({ + status: baseAuthenticatedStatus, + session, + profileTeam: "ENG", + }); + + const report = await buildAuthStatusReport(manager); + + expect(report.user).toEqual({ id: "u1", name: "Daniel", email: "dan@example.com" }); + expect(report.workspace).toEqual({ id: "org-1", name: "Acme", urlKey: "acme" }); + expect(report.defaultTeam).toEqual({ id: "t1", key: "ENG", name: "Engineering" }); + expect(report.profile).toBe("default"); + }); + + test("uses the status-resolved profile for live session and config lookup", async () => { + const session = makeSession({ + viewer: { id: "u1", name: "Dan", email: "dan@example.com" }, + organization: { id: "org-1", name: "Acme", urlKey: "acme" }, + }); + const manager: AuthStatusReportManager = { + status: vi.fn().mockResolvedValue({ + ...baseAuthenticatedStatus, + profile: "work", + }), + openSession: vi.fn().mockResolvedValue(session), + getProfile: vi.fn().mockResolvedValue({}), + }; + + const report = await buildAuthStatusReport(manager); + + expect(report.profile).toBe("work"); + expect(manager.openSession).toHaveBeenCalledWith({ profile: "work" }); + expect(manager.getProfile).toHaveBeenCalledWith("work"); + }); + + test("authenticated profile with no team configured returns null defaultTeam", async () => { + const session = makeSession({ + viewer: { id: "u1", name: "Dan", email: "dan@example.com" }, + organization: { id: "org-1", name: "Acme", urlKey: "acme" }, + }); + const manager = makeManager({ status: baseAuthenticatedStatus, session }); + + const report = await buildAuthStatusReport(manager); + expect(report.defaultTeam).toBeNull(); + }); + + test("unauthenticated profile returns null live fields and base status", async () => { + const manager = makeManager({ status: unauthenticatedStatus }); + const report = await buildAuthStatusReport(manager); + + expect(report.user).toBeNull(); + expect(report.workspace).toBeNull(); + expect(report.defaultTeam).toBeNull(); + expect(report.hasAccessToken).toBe(false); + expect(report.profile).toBe("default"); + }); + + test("viewer rejection still produces ok report with user null", async () => { + const session = makeSession({ + viewer: new Error("boom"), + organization: { id: "org-1", name: "Acme", urlKey: "acme" }, + }); + const manager = makeManager({ status: baseAuthenticatedStatus, session }); + + const report = await buildAuthStatusReport(manager); + expect(report.user).toBeNull(); + expect(report.workspace).toEqual({ id: "org-1", name: "Acme", urlKey: "acme" }); + }); + + test("default team key resolves from the teams page", async () => { + const session = makeSession({ + viewer: { id: "u1", name: "Dan", email: "dan@example.com" }, + organization: { id: "org-1", name: "Acme", urlKey: "acme" }, + listTeams: [ + { id: "t-other", key: "OPS", name: "Ops" }, + { id: "t1", key: "ENG", name: "Engineering" }, + ], + }); + const manager = makeManager({ + status: baseAuthenticatedStatus, + session, + profileTeam: "ENG", + }); + + const report = await buildAuthStatusReport(manager); + expect(report.defaultTeam).toEqual({ id: "t1", key: "ENG", name: "Engineering" }); + }); + + test("default team key lookup checks subsequent team pages", async () => { + const session = makeSession({ + viewer: { id: "u1", name: "Dan", email: "dan@example.com" }, + organization: { id: "org-1", name: "Acme", urlKey: "acme" }, + listTeams: [ + { + items: [{ id: "t-other", key: "OPS", name: "Ops" }], + nextCursor: "cursor-2", + }, + { + items: [{ id: "t1", key: "ENG", name: "Engineering" }], + nextCursor: null, + }, + ], + }); + const manager = makeManager({ + status: baseAuthenticatedStatus, + session, + profileTeam: "ENG", + }); + + const report = await buildAuthStatusReport(manager); + expect(report.defaultTeam).toEqual({ id: "t1", key: "ENG", name: "Engineering" }); + }); + + test("openSession failure degrades to null live fields without throwing", async () => { + const manager = makeManager({ + status: baseAuthenticatedStatus, + session: new Error("nope"), + }); + + const report = await buildAuthStatusReport(manager); + expect(report.user).toBeNull(); + expect(report.workspace).toBeNull(); + expect(report.defaultTeam).toBeNull(); + }); +}); diff --git a/packages/linear-core/src/auth/session.ts b/packages/linear-core/src/auth/session.ts index 933fc5f..0c7ba57 100644 --- a/packages/linear-core/src/auth/session.ts +++ b/packages/linear-core/src/auth/session.ts @@ -1,6 +1,6 @@ import { LinearClient } from "@linear/sdk"; import { ConfigStore } from "../config/config-store.js"; -import type { OAuthProfileConfig } from "../config/schema.js"; +import type { OAuthProfileConfig, ProfileConfig } from "../config/schema.js"; import { LinearGateway } from "../entities/linear-gateway.js"; import { LinearCoreError } from "../errors/core-error.js"; import { createCredentialStore } from "../token-store/composite-store.js"; @@ -95,6 +95,17 @@ export class AuthManager { } } + public async getProfile(profile?: string): Promise { + try { + return await this.configStore.getProfile(profile); + } catch (error) { + if (error instanceof LinearCoreError && error.code === "CONFIG_NOT_FOUND") { + return undefined; + } + throw error; + } + } + public async saveOAuthConfig(profile: string, oauth: OAuthProfileConfig): Promise { await this.configStore.mergeProfile(profile, { oauth, diff --git a/packages/linear-core/tests/session.test.ts b/packages/linear-core/tests/session.test.ts index 439a226..86f6c74 100644 --- a/packages/linear-core/tests/session.test.ts +++ b/packages/linear-core/tests/session.test.ts @@ -112,4 +112,49 @@ describe("AuthManager", () => { expect(credentialStore.state.get("default")?.accessToken).toBe("fresh-access"); vi.unstubAllGlobals(); }); + + test("getProfile returns the stored profile when present", async () => { + const configStore = new ConfigStore("/tmp/linear-auth-config-unused.json"); + vi.spyOn(configStore, "load").mockResolvedValue({ + version: 2, + defaultProfile: "default", + profiles: { + default: { + name: "default", + preferredAuth: "api-key", + team: "ENG", + }, + }, + }); + + const credentialStore = createMemoryStore({}); + const manager = new AuthManager(configStore, Promise.resolve(credentialStore)); + + const profile = await manager.getProfile(); + expect(profile).toEqual({ + name: "default", + preferredAuth: "api-key", + team: "ENG", + }); + }); + + test("getProfile returns undefined when profile is not configured", async () => { + const configStore = new ConfigStore("/tmp/linear-auth-config-unused.json"); + vi.spyOn(configStore, "load").mockResolvedValue({ + version: 2, + defaultProfile: "default", + profiles: { + default: { + name: "default", + preferredAuth: "oauth", + }, + }, + }); + + const credentialStore = createMemoryStore({}); + const manager = new AuthManager(configStore, Promise.resolve(credentialStore)); + + const profile = await manager.getProfile("missing"); + expect(profile).toBeUndefined(); + }); });