diff --git a/packages/cli/src/formatters/output.ts b/packages/cli/src/formatters/output.ts index 3a24dfa..88d8331 100644 --- a/packages/cli/src/formatters/output.ts +++ b/packages/cli/src/formatters/output.ts @@ -122,6 +122,42 @@ function pickFields( return Object.fromEntries(fields.map((field) => [field, item[field]])); } +function isSensitiveJsonKey(key: string): boolean { + const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, ""); + return ( + normalized.includes("apikey") || + normalized.includes("password") || + normalized.includes("secret") || + normalized.includes("token") || + normalized.includes("authorizationurl") || + normalized.includes("clientid") || + normalized.includes("redirecturi") || + normalized.includes("scope") || + normalized.includes("codeverifier") + ); +} + +function redactJsonData(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => redactJsonData(item)); + } + + if (!isRecord(value)) { + return value; + } + + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [ + key, + isSensitiveJsonKey(key) ? "[REDACTED]" : redactJsonData(entry), + ]), + ); +} + +function stringifyJsonOutput(value: unknown): string { + return JSON.stringify(redactJsonData(value), null, 2); +} + function toHumanRowsWithOptions( items: readonly unknown[], options: Pick, @@ -179,9 +215,32 @@ function printHumanData(data: unknown, options: GlobalOptions): void { console.log(String(data)); } +function projectJsonData(data: unknown, fields: readonly string[]): unknown { + if (Array.isArray(data)) { + return data.map((item) => (isRecord(item) ? pickFields(item, fields) : item)); + } + + if (isPageResult(data)) { + return { + ...data, + items: data.items.map((item) => (isRecord(item) ? pickFields(item, fields) : item)), + }; + } + + if (isRecord(data)) { + return pickFields(data, fields); + } + + return data; +} + export function renderEnvelope(envelope: OutputEnvelope, options: GlobalOptions): void { if (options.json) { - console.log(JSON.stringify(envelope, null, 2)); + const projected = + envelope.ok && options.fields && options.fields.length > 0 + ? { ...envelope, data: projectJsonData(envelope.data, options.fields) } + : envelope; + console.log(stringifyJsonOutput(projected)); return; } @@ -196,7 +255,7 @@ export function renderEnvelope(envelope: OutputEnvelope, options: Gl if (isErrorEnvelope(envelope)) { console.error(`${envelope.entity}.${envelope.action} failed: ${envelope.error.message}`); if (envelope.error.details) { - console.error(JSON.stringify(envelope.error.details, null, 2)); + console.error(stringifyJsonOutput(envelope.error.details)); } } } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 531643b..3d7050d 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -319,7 +319,10 @@ export function createProgram(authManager = new AuthManager()): Command { .option("--sort ", "Sort by a field, prefix with - for descending") .option("--view ", "Human output preset: table | detail | dense") .option("--all", "Drain all pages before filtering") - .option("--fields ", "Comma-separated field selection for human output"); + .option( + "--fields ", + "Comma-separated field selection (applies to JSON and human output)", + ); const authCommand = program.command("auth").description("Authentication commands"); diff --git a/packages/cli/tests/output.test.ts b/packages/cli/tests/output.test.ts index 266de87..c1f1e55 100644 --- a/packages/cli/tests/output.test.ts +++ b/packages/cli/tests/output.test.ts @@ -119,6 +119,76 @@ describe("renderEnvelope", () => { expect(logSpy).toHaveBeenCalledWith("issues.get"); }); + test("applies --fields to issues JSON output and preserves nextCursor", () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + renderEnvelope( + successEnvelope("issues", "list", { + items: [ + { + id: "issue-1", + identifier: "ENG-1", + title: "Fix output formatting", + priority: 2, + stateName: "Todo", + stateType: "unstarted", + updatedAt: "2026-03-16T17:00:00.000Z", + }, + ], + nextCursor: "cursor-2", + }), + { + json: true, + quiet: false, + fields: ["identifier", "stateType"], + }, + ); + + const output = logSpy.mock.calls[0]?.[0]; + expect(typeof output).toBe("string"); + const parsed = JSON.parse(String(output)); + expect(parsed.ok).toBe(true); + expect(parsed.data.items).toEqual([{ identifier: "ENG-1", stateType: "unstarted" }]); + expect(parsed.data.nextCursor).toBe("cursor-2"); + }); + + test("redacts sensitive fields from JSON output", () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + renderEnvelope( + successEnvelope("auth", "login", { + profile: "default", + method: "oauth", + apiKey: "lin_api_secret", + accessToken: "access_secret", + refreshToken: "refresh_secret", + authorizationUrl: "https://linear.app/oauth/authorize?state=secret_state", + redirectUri: "http://127.0.0.1:8787/oauth/callback", + oauth: { + clientId: "client_secretish", + tokenUrl: "https://api.linear.app/oauth/token", + scopes: ["read", "write"], + nested: { + password: "pw_secret", + }, + }, + }), + { + json: true, + quiet: false, + }, + ); + + const output = String(logSpy.mock.calls[0]?.[0]); + expect(output).not.toContain("lin_api_secret"); + expect(output).not.toContain("access_secret"); + expect(output).not.toContain("refresh_secret"); + expect(output).not.toContain("secret_state"); + expect(output).not.toContain("client_secretish"); + expect(output).not.toContain("pw_secret"); + expect(output).toContain("[REDACTED]"); + }); + test("supports detail views with field selection for issue lists", () => { const tableSpy = vi.spyOn(console, "table").mockImplementation(() => {}); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); diff --git a/packages/linear-core/src/entities/linear-gateway.ts b/packages/linear-core/src/entities/linear-gateway.ts index 7f5b516..9bc7d3b 100644 --- a/packages/linear-core/src/entities/linear-gateway.ts +++ b/packages/linear-core/src/entities/linear-gateway.ts @@ -109,25 +109,35 @@ function readDisplayName( } async function toIssue(record: SdkIssueLike): Promise { - const [state, assignee, project, cycle, team, milestone, parent, labels, children, relations] = - await Promise.all([ - resolveFetch(record.state), - resolveFetch(record.assignee), - resolveFetch(record.project), - resolveFetch(record.cycle), - resolveFetch(record.team), - resolveFetch(record.projectMilestone), - resolveFetch(record.parent), - resolveConnectionNodes( - typeof record.labels === "function" ? () => record.labels() : undefined, - ), - resolveConnectionNodes( - typeof record.children === "function" ? () => record.children() : undefined, - ), - resolveConnectionNodes( - typeof record.relations === "function" ? () => record.relations() : undefined, - ), - ]); + const [ + state, + assignee, + creator, + project, + cycle, + team, + milestone, + parent, + labels, + children, + relations, + ] = await Promise.all([ + resolveFetch(record.state), + resolveFetch(record.assignee), + resolveFetch(record.creator), + resolveFetch(record.project), + resolveFetch(record.cycle), + resolveFetch(record.team), + resolveFetch(record.projectMilestone), + resolveFetch(record.parent), + resolveConnectionNodes(typeof record.labels === "function" ? () => record.labels() : undefined), + resolveConnectionNodes( + typeof record.children === "function" ? () => record.children() : undefined, + ), + resolveConnectionNodes( + typeof record.relations === "function" ? () => record.relations() : undefined, + ), + ]); const childrenCount = children.length; @@ -141,9 +151,13 @@ async function toIssue(record: SdkIssueLike): Promise { priority: record.priority, estimate: record.estimate ?? undefined, dueDate: record.dueDate ?? undefined, + stateId: state?.id, stateName: state?.name, + stateType: state?.type, assigneeId: record.assigneeId ?? undefined, assigneeName: readDisplayName(assignee), + creatorId: record.creatorId ?? undefined, + creatorName: readDisplayName(creator), teamId: record.teamId, teamKey: team?.key, teamName: team?.displayName ?? team?.name, @@ -159,6 +173,16 @@ async function toIssue(record: SdkIssueLike): Promise { labelNames: labels .map((label) => label.name) .filter((value): value is string => typeof value === "string"), + labels: labels + .filter( + (label): label is typeof label & { id: string; name: string } => + typeof label.id === "string" && typeof label.name === "string", + ) + .map((label) => ({ + id: label.id, + name: label.name, + ...(label.color ? { color: label.color } : {}), + })), childCount: childrenCount, childrenCount, hasChildren: childrenCount > 0, @@ -166,6 +190,9 @@ async function toIssue(record: SdkIssueLike): Promise { url: record.url, createdAt: toDateString(record.createdAt), updatedAt: toDateString(record.updatedAt), + completedAt: record.completedAt ? toDateString(record.completedAt) : undefined, + canceledAt: record.canceledAt ? toDateString(record.canceledAt) : undefined, + archivedAt: record.archivedAt ? toDateString(record.archivedAt) : undefined, }; } diff --git a/packages/linear-core/src/entities/models.ts b/packages/linear-core/src/entities/models.ts index c76a813..06de308 100644 --- a/packages/linear-core/src/entities/models.ts +++ b/packages/linear-core/src/entities/models.ts @@ -1,3 +1,9 @@ +export interface IssueLabelSummary { + readonly id: string; + readonly name: string; + readonly color?: string; +} + export interface IssueRecord { readonly id: string; readonly number: number; @@ -8,9 +14,13 @@ export interface IssueRecord { readonly priority: number; readonly estimate?: number; readonly dueDate?: string; + readonly stateId?: string; readonly stateName?: string; + readonly stateType?: string; readonly assigneeId?: string; readonly assigneeName?: string; + readonly creatorId?: string; + readonly creatorName?: string; readonly teamId?: string; readonly teamKey?: string; readonly teamName?: string; @@ -24,6 +34,7 @@ export interface IssueRecord { readonly parentIdentifier?: string; readonly parentTitle?: string; readonly labelNames?: readonly string[]; + readonly labels?: readonly IssueLabelSummary[]; readonly childCount?: number; readonly childrenCount?: number; readonly hasChildren?: boolean; @@ -31,6 +42,9 @@ export interface IssueRecord { readonly url: string; readonly createdAt: string; readonly updatedAt: string; + readonly completedAt?: string; + readonly canceledAt?: string; + readonly archivedAt?: string; } export interface ProjectRecord { diff --git a/packages/linear-core/tests/linear-gateway.test.ts b/packages/linear-core/tests/linear-gateway.test.ts index 80671f1..b515dd4 100644 --- a/packages/linear-core/tests/linear-gateway.test.ts +++ b/packages/linear-core/tests/linear-gateway.test.ts @@ -21,6 +21,15 @@ function createTestClient(): SdkLinearClient { url: "https://linear.app/issue/ENG-1", createdAt: new Date("2024-01-01T00:00:00.000Z"), updatedAt: new Date("2024-01-02T00:00:00.000Z"), + completedAt: new Date("2024-01-03T00:00:00.000Z"), + canceledAt: new Date("2024-01-04T00:00:00.000Z"), + archivedAt: new Date("2024-01-05T00:00:00.000Z"), + creatorId: "user_2", + creator: Promise.resolve({ + id: "user_2", + name: "Sam Creator", + displayName: "Sam", + }), state: Promise.resolve({ id: "s_1", name: "In Progress", @@ -47,6 +56,15 @@ function createTestClient(): SdkLinearClient { url: "https://linear.app/issue/ENG-1", createdAt: new Date("2024-01-01T00:00:00.000Z"), updatedAt: new Date("2024-01-02T00:00:00.000Z"), + completedAt: new Date("2024-01-03T00:00:00.000Z"), + canceledAt: new Date("2024-01-04T00:00:00.000Z"), + archivedAt: new Date("2024-01-05T00:00:00.000Z"), + creatorId: "user_2", + creator: Promise.resolve({ + id: "user_2", + name: "Sam Creator", + displayName: "Sam", + }), state: Promise.resolve({ id: "s_1", name: "In Progress", @@ -524,6 +542,13 @@ describe("LinearGateway", () => { expect(result.items[0]?.identifier).toBe("ENG-1"); expect(result.items[0]?.branchName).toBe("eng-1-fix-parser"); expect(result.items[0]?.stateName).toBe("In Progress"); + expect(result.items[0]?.stateId).toBe("s_1"); + expect(result.items[0]?.stateType).toBe("started"); + expect(result.items[0]?.creatorId).toBe("user_2"); + expect(result.items[0]?.creatorName).toBe("Sam"); + expect(result.items[0]?.completedAt).toBe("2024-01-03T00:00:00.000Z"); + expect(result.items[0]?.canceledAt).toBe("2024-01-04T00:00:00.000Z"); + expect(result.items[0]?.archivedAt).toBe("2024-01-05T00:00:00.000Z"); expect(result.nextCursor).toBe("cursor-2"); }); @@ -543,6 +568,13 @@ describe("LinearGateway", () => { expect(result.identifier).toBe("ENG-1"); expect(result.description).toBe("Parser crashes when a trailing comma is present."); expect(result.stateName).toBe("In Progress"); + expect(result.stateId).toBe("s_1"); + expect(result.stateType).toBe("started"); + expect(result.creatorId).toBe("user_2"); + expect(result.creatorName).toBe("Sam"); + expect(result.completedAt).toBe("2024-01-03T00:00:00.000Z"); + expect(result.canceledAt).toBe("2024-01-04T00:00:00.000Z"); + expect(result.archivedAt).toBe("2024-01-05T00:00:00.000Z"); }); test("archives cycle via delete operation", async () => { diff --git a/packages/linear-core/tests/v2-gateway.test.ts b/packages/linear-core/tests/v2-gateway.test.ts index 0e24c1f..680c14c 100644 --- a/packages/linear-core/tests/v2-gateway.test.ts +++ b/packages/linear-core/tests/v2-gateway.test.ts @@ -360,6 +360,7 @@ describe("LinearGateway v2", () => { expect(issue.parentIdentifier).toBe("ENG-0"); expect(issue.parentTitle).toBe("Parent issue"); expect(issue.labelNames).toEqual(["Bug"]); + expect(issue.labels).toEqual([{ id: "label_1", name: "Bug", color: "#ff0000" }]); expect(issue.childCount).toBe(1); expect(issue.childrenCount).toBe(1); expect(issue.hasChildren).toBe(true);