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
20 changes: 15 additions & 5 deletions packages/cli/src/bin/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,18 @@ import { createProgram } from "../index.js";

const program = createProgram();

program.parseAsync(process.argv).catch((error: unknown) => {
const message = error instanceof Error ? error.message : "Unknown CLI error";
console.error(message);
process.exitCode = 1;
});
const drainAndExit = (code: number): void => {
Promise.all([
new Promise<void>((resolve) => process.stdout.write("", () => resolve())),
new Promise<void>((resolve) => process.stderr.write("", () => resolve())),
]).finally(() => process.exit(code));
};

program
.parseAsync(process.argv)
.then(() => drainAndExit(typeof process.exitCode === "number" ? process.exitCode : 0))
.catch((error: unknown) => {
const message = error instanceof Error ? error.message : "Unknown CLI error";
console.error(message);
drainAndExit(1);
});
11 changes: 7 additions & 4 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,9 +316,9 @@ export function createProgram(authManager = new AuthManager()): Command {
.option("--sort <field>", "Sort by a field, prefix with - for descending")
.option("--view <preset>", "Human output preset: table | detail | dense")
.option("--all", "Drain all pages before filtering")
.option(
"--fields <list>",
"Comma-separated field selection (applies to JSON and human output)",
.option("--fields <list>", "Comma-separated field selection (applies to JSON and human output)")
.option("--timeout <seconds>", "Per-request network timeout in seconds (default 30)", (value) =>
Number.parseInt(value, 10),
);

const authCommand = program.command("auth").description("Authentication commands");
Expand Down Expand Up @@ -490,7 +490,10 @@ export function createProgram(authManager = new AuthManager()): Command {

const openSessionForCommand = async (cmd: Command) => {
const globals = getGlobalOptions(cmd);
return authManager.openSession({ profile: globals.profile });
return authManager.openSession({
profile: globals.profile,
timeoutMs: globals.timeoutMs,
});
};
Comment on lines 491 to 497

const sessionGateway = async (cmd: Command) => (await openSessionForCommand(cmd)).gateway;
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/runtime/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@ export interface GlobalOptions {
readonly view?: "table" | "detail" | "dense";
readonly all?: boolean;
readonly fields?: readonly string[];
readonly timeoutMs: number;
}

const DEFAULT_TIMEOUT_SECONDS = 30;

export function getGlobalOptions(command: Command): GlobalOptions {
const rawValue = command.optsWithGlobals();
const raw = isRecord(rawValue) ? rawValue : {};
Expand All @@ -31,6 +34,12 @@ export function getGlobalOptions(command: Command): GlobalOptions {
.map((value) => value.trim())
.filter((value) => value.length > 0);

const timeoutSeconds = readNumber(raw.timeout);
const timeoutMs =
timeoutSeconds !== undefined && timeoutSeconds > 0
? timeoutSeconds * 1000
: DEFAULT_TIMEOUT_SECONDS * 1000;

return {
json: readBoolean(raw.json),
quiet: readBoolean(raw.quiet),
Expand All @@ -51,5 +60,6 @@ export function getGlobalOptions(command: Command): GlobalOptions {
...(readString(raw.view) ? { view: readString(raw.view) as "table" | "detail" | "dense" } : {}),
...(readBoolean(raw.all) ? { all: true } : {}),
...(fields && fields.length > 0 ? { fields } : {}),
timeoutMs,
};
}
63 changes: 42 additions & 21 deletions packages/cli/tests/options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,35 @@ import { Command } from "commander";
import { describe, expect, test } from "vitest";
import { getGlobalOptions } from "../src/runtime/options.js";

function buildProgram(): Command {
const program = new Command();
program
.option("--json")
.option("--profile <name>")
.option("--team <key>")
.option("--limit <n>", (value) => Number.parseInt(value, 10))
.option("--cursor <cursor>")
.option("--quiet")
.option("--mine")
.option("--project <id>")
.option("--cycle <id>")
.option("--state <name>")
.option("--assignee <name>")
.option("--label <name>")
.option("--priority <value>")
.option("--status <name>")
.option("--filter <expr>")
.option("--sort <field>")
.option("--view <preset>")
.option("--all")
.option("--fields <list>")
.option("--timeout <seconds>", "Per-request timeout", (value) => Number.parseInt(value, 10));
return program;
}

describe("getGlobalOptions", () => {
test("parses shared v2 query and presentation options", () => {
const program = new Command();
program
.option("--json")
.option("--profile <name>")
.option("--team <key>")
.option("--limit <n>", (value) => Number.parseInt(value, 10))
.option("--cursor <cursor>")
.option("--quiet")
.option("--mine")
.option("--project <id>")
.option("--cycle <id>")
.option("--state <name>")
.option("--assignee <name>")
.option("--label <name>")
.option("--priority <value>")
.option("--status <name>")
.option("--filter <expr>")
.option("--sort <field>")
.option("--view <preset>")
.option("--all")
.option("--fields <list>");
const program = buildProgram();

program.parse([
"node",
Expand Down Expand Up @@ -80,6 +86,21 @@ describe("getGlobalOptions", () => {
view: "detail",
all: true,
fields: ["identifier", "title", "assigneeName"],
timeoutMs: 30_000,
});
});

test("converts --timeout seconds to timeoutMs", () => {
const program = buildProgram();
program.parse(["node", "linear", "--timeout", "5"]);

expect(getGlobalOptions(program).timeoutMs).toBe(5_000);
});

test("defaults timeoutMs to 30s when --timeout absent", () => {
const program = buildProgram();
program.parse(["node", "linear"]);

expect(getGlobalOptions(program).timeoutMs).toBe(30_000);
});
});
4 changes: 4 additions & 0 deletions packages/linear-core/src/auth/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,14 @@ interface TokenExchangeInput {
readonly code: string;
readonly redirectUri: string;
readonly codeVerifier: string;
readonly signal?: AbortSignal;
}

interface TokenRefreshInput {
readonly clientId: string;
readonly tokenUrl: string;
readonly refreshToken: string;
readonly signal?: AbortSignal;
}

const tokenResponseSchema = z.object({
Expand Down Expand Up @@ -172,6 +174,7 @@ export async function exchangeAuthorizationCode(
"content-type": "application/x-www-form-urlencoded",
},
body,
signal: input.signal,
});

return parseTokenResponse(response);
Expand All @@ -193,6 +196,7 @@ export async function refreshOAuthToken(
"content-type": "application/x-www-form-urlencoded",
},
body,
signal: input.signal,
});

const token = await parseTokenResponse(response);
Expand Down
45 changes: 42 additions & 3 deletions packages/linear-core/src/auth/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,20 @@ export interface AuthorizationCodeLoginInput {
readonly code: string;
readonly redirectUri: string;
readonly codeVerifier: string;
readonly signal?: AbortSignal;
}

export interface RefreshTokenInput {
readonly profile: string;
readonly clientId: string;
readonly tokenUrl: string;
readonly signal?: AbortSignal;
}

export interface OpenSessionOptions {
readonly profile?: string;
readonly timeoutMs?: number;
readonly signal?: AbortSignal;
}

export interface ActiveSession {
Expand All @@ -59,6 +67,26 @@ export interface ActiveSession {
readonly credentials: StoredCredentials;
}

function resolveRequestSignal(options?: OpenSessionOptions): AbortSignal | undefined {
if (!options) {
return undefined;
}
const signals: AbortSignal[] = [];
if (options.signal) {
signals.push(options.signal);
}
if (typeof options.timeoutMs === "number" && options.timeoutMs > 0) {
signals.push(AbortSignal.timeout(options.timeoutMs));
}
if (signals.length === 0) {
return undefined;
}
if (signals.length === 1) {
return signals[0];
}
return AbortSignal.any(signals);
}

function toStoredCredentials(token: OAuthToken): StoredCredentials {
return {
accessToken: token.accessToken,
Expand Down Expand Up @@ -138,6 +166,7 @@ export class AuthManager {
code: input.code,
redirectUri: input.redirectUri,
codeVerifier: input.codeVerifier,
signal: input.signal,
});

await this.loginWithToken({
Expand All @@ -163,6 +192,7 @@ export class AuthManager {
clientId: input.clientId,
tokenUrl: input.tokenUrl,
refreshToken: existing.refreshToken,
signal: input.signal,
});

await store.set(input.profile, {
Expand Down Expand Up @@ -210,13 +240,14 @@ export class AuthManager {
};
}

public async openSession(options?: { readonly profile?: string }): Promise<ActiveSession> {
public async openSession(options?: OpenSessionOptions): Promise<ActiveSession> {
const config = await this.configStore.load();
const selectedProfile = options?.profile ?? config.defaultProfile;
const selected = config.profiles[selectedProfile];
const store = await this.credentialsStore();
const credentials = await store.get(selectedProfile);
const oauthConfig = selected?.oauth;
const requestSignal = resolveRequestSignal(options);

if (credentials?.accessToken) {
let token: OAuthToken = {
Expand All @@ -230,6 +261,7 @@ export class AuthManager {
clientId: oauthConfig.clientId,
tokenUrl: oauthConfig.tokenUrl,
refreshToken: token.refreshToken,
signal: requestSignal,
});
token = refreshed;
await store.set(selectedProfile, {
Expand All @@ -240,6 +272,7 @@ export class AuthManager {

const client = new LinearClient({
accessToken: token.accessToken,
...(requestSignal ? { signal: requestSignal } : {}),
});

return {
Expand All @@ -255,7 +288,10 @@ export class AuthManager {
}

if (credentials?.apiKey) {
const client = new LinearClient({ apiKey: credentials.apiKey });
const client = new LinearClient({
apiKey: credentials.apiKey,
...(requestSignal ? { signal: requestSignal } : {}),
});
return {
profile: selectedProfile,
client,
Expand All @@ -268,7 +304,10 @@ export class AuthManager {

const envApiKey = process.env.LINEAR_API_KEY;
if (envApiKey) {
const client = new LinearClient({ apiKey: envApiKey });
const client = new LinearClient({
apiKey: envApiKey,
...(requestSignal ? { signal: requestSignal } : {}),
});
return {
profile: selectedProfile,
client,
Expand Down
Loading
Loading