Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions packages/cli/src/auth/status-report.ts
Original file line number Diff line number Diff line change
@@ -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<AuthStatus>;
openSession(options?: { readonly profile?: string }): Promise<ActiveSession>;
getProfile(profile?: string): Promise<{ readonly team?: string } | undefined>;
}

async function fetchUser(session: ActiveSession): Promise<AuthStatusUser | null> {
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<AuthStatusWorkspace | null> {
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<AuthStatusTeam | null> {
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<AuthStatusReport> {
const status = await authManager.status(profile);
const resolvedProfile = status.profile;

if (!status.hasAccessToken && !status.hasApiKey) {
return { ...status, user: null, workspace: null, defaultTeam: null };
}
Comment thread
dubscode marked this conversation as resolved.

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 };
}
5 changes: 3 additions & 2 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand Down
222 changes: 222 additions & 0 deletions packages/cli/tests/auth-status.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
13 changes: 12 additions & 1 deletion packages/linear-core/src/auth/session.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -95,6 +95,17 @@ export class AuthManager {
}
}

public async getProfile(profile?: string): Promise<ProfileConfig | undefined> {
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<void> {
await this.configStore.mergeProfile(profile, {
oauth,
Expand Down
Loading
Loading