Files
orca/src/main/linear/client.ts
T

704 lines
22 KiB
TypeScript

/* eslint-disable max-lines -- Why: Linear credential storage and client
selection share one module so keychain-safe status reads and token mutation
stay in one consistency boundary. */
import { safeStorage } from 'electron'
import type { LinearClient } from '@linear/sdk'
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { loadLinearSdk } from './linear-sdk'
import {
CredentialDecryptionError,
credentialFileHasContent,
readStoredCredentialToken
} from '../integration-credential-file'
import type {
LinearConnectionStatus,
LinearViewer,
LinearWorkspace,
LinearWorkspaceSelection
} from '../../shared/types'
// ── Concurrency limiter — max 4 parallel Linear API calls ────────────
const MAX_CONCURRENT = 4
let running = 0
const queue: (() => void)[] = []
export function acquire(): Promise<void> {
if (running < MAX_CONCURRENT) {
running++
return Promise.resolve()
}
return new Promise((resolve) =>
queue.push(() => {
running++
resolve()
})
)
}
export function release(): void {
running--
const next = queue.shift()
if (next) {
next()
}
}
// ── Token + workspace storage ────────────────────────────────────────
// Why: tokens remain encrypted via safeStorage, while workspace metadata stays
// plaintext so status checks can render connected accounts without decrypting
// and triggering OS keychain prompts after app updates.
const LEGACY_WORKSPACE_ID = 'legacy'
type LinearWorkspaceFile = {
version: 1
activeWorkspaceId: string | null
selectedWorkspaceId: LinearWorkspaceSelection | null
workspaces: LinearWorkspace[]
}
export type LinearClientForWorkspace = {
workspace: LinearWorkspace
client: LinearClient
apiKey: string
}
export const LINEAR_PUBLIC_FILE_URL_EXPIRY_SECONDS = 60 * 60
let cachedTokens = new Map<string, string>()
// Why: decrypt failures are recorded per workspace so getStatus can explain
// failing reads without re-touching the keychain on every status poll.
const credentialErrors = new Map<string, string>()
let cachedLegacyViewer: LinearViewer | null = null
let legacyViewerLoadedFromDisk = false
let cachedWorkspaceFile: LinearWorkspaceFile | null = null
let workspaceFileLoadedFromDisk = false
function getOrcaDir(): string {
return join(homedir(), '.orca')
}
function getLegacyTokenPath(): string {
return join(getOrcaDir(), 'linear-token.enc')
}
function getLegacyViewerPath(): string {
return join(getOrcaDir(), 'linear-viewer.json')
}
function getWorkspaceFilePath(): string {
return join(getOrcaDir(), 'linear-workspaces.json')
}
function getWorkspaceTokenDir(): string {
return join(getOrcaDir(), 'linear-tokens')
}
function getWorkspaceTokenPath(workspaceId: string): string {
if (workspaceId === LEGACY_WORKSPACE_ID) {
return getLegacyTokenPath()
}
return join(getWorkspaceTokenDir(), `${Buffer.from(workspaceId).toString('base64url')}.enc`)
}
function ensureOrcaDir(): void {
const dir = getOrcaDir()
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
}
function ensureWorkspaceTokenDir(): void {
const dir = getWorkspaceTokenDir()
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
}
function readLegacyViewerFromDisk(): LinearViewer | null {
const path = getLegacyViewerPath()
if (!existsSync(path)) {
return null
}
try {
const raw = readFileSync(path, { encoding: 'utf-8' })
const parsed = JSON.parse(raw) as Partial<LinearViewer>
if (typeof parsed?.displayName !== 'string' || typeof parsed?.organizationName !== 'string') {
return null
}
return {
displayName: parsed.displayName,
email: typeof parsed.email === 'string' ? parsed.email : null,
organizationId: typeof parsed.organizationId === 'string' ? parsed.organizationId : undefined,
organizationName: parsed.organizationName,
organizationUrlKey:
typeof parsed.organizationUrlKey === 'string' ? parsed.organizationUrlKey : undefined
}
} catch {
return null
}
}
function getLegacyViewer(): LinearViewer | null {
if (!legacyViewerLoadedFromDisk) {
cachedLegacyViewer = readLegacyViewerFromDisk()
legacyViewerLoadedFromDisk = true
}
return cachedLegacyViewer
}
function normalizeWorkspace(input: unknown): LinearWorkspace | null {
if (!input || typeof input !== 'object') {
return null
}
const record = input as Record<string, unknown>
if (typeof record.id !== 'string' || typeof record.organizationName !== 'string') {
return null
}
if (typeof record.displayName !== 'string') {
return null
}
const organizationId =
typeof record.organizationId === 'string' && record.organizationId
? record.organizationId
: record.id
return {
id: record.id,
organizationId,
organizationName: record.organizationName,
organizationUrlKey:
typeof record.organizationUrlKey === 'string' ? record.organizationUrlKey : undefined,
displayName: record.displayName,
email: typeof record.email === 'string' ? record.email : null,
credentialRevision:
typeof record.credentialRevision === 'number' && Number.isFinite(record.credentialRevision)
? record.credentialRevision
: undefined
}
}
function emptyWorkspaceFile(): LinearWorkspaceFile {
return {
version: 1,
activeWorkspaceId: null,
selectedWorkspaceId: null,
workspaces: []
}
}
function readWorkspaceFileFromDisk(): LinearWorkspaceFile {
const path = getWorkspaceFilePath()
if (!existsSync(path)) {
return emptyWorkspaceFile()
}
try {
const raw = readFileSync(path, { encoding: 'utf-8' })
const parsed = JSON.parse(raw) as Partial<LinearWorkspaceFile>
const workspaces = Array.isArray(parsed.workspaces)
? parsed.workspaces
.map((workspace) => normalizeWorkspace(workspace))
.filter((workspace): workspace is LinearWorkspace => workspace !== null)
.filter((workspace) => hasStoredToken(workspace.id))
: []
const activeWorkspaceId =
typeof parsed.activeWorkspaceId === 'string' &&
workspaces.some((workspace) => workspace.id === parsed.activeWorkspaceId)
? parsed.activeWorkspaceId
: (workspaces[0]?.id ?? null)
const selectedWorkspaceId =
parsed.selectedWorkspaceId === 'all' ||
(typeof parsed.selectedWorkspaceId === 'string' &&
workspaces.some((workspace) => workspace.id === parsed.selectedWorkspaceId))
? parsed.selectedWorkspaceId
: activeWorkspaceId
return {
version: 1,
activeWorkspaceId,
selectedWorkspaceId,
workspaces
}
} catch {
return emptyWorkspaceFile()
}
}
function getWorkspaceFile(): LinearWorkspaceFile {
if (!workspaceFileLoadedFromDisk || !cachedWorkspaceFile) {
cachedWorkspaceFile = readWorkspaceFileFromDisk()
workspaceFileLoadedFromDisk = true
}
return cachedWorkspaceFile
}
function writeWorkspaceFile(file: LinearWorkspaceFile): void {
ensureOrcaDir()
const persistedWorkspaces = file.workspaces.filter(
(workspace) => workspace.id !== LEGACY_WORKSPACE_ID
)
const selectableIds = new Set(persistedWorkspaces.map((workspace) => workspace.id))
if (hasStoredToken(LEGACY_WORKSPACE_ID)) {
selectableIds.add(LEGACY_WORKSPACE_ID)
}
const activeWorkspaceId =
file.activeWorkspaceId && selectableIds.has(file.activeWorkspaceId)
? file.activeWorkspaceId
: (persistedWorkspaces[0]?.id ??
(selectableIds.has(LEGACY_WORKSPACE_ID) ? LEGACY_WORKSPACE_ID : null))
const selectedWorkspaceId =
file.selectedWorkspaceId === 'all'
? 'all'
: file.selectedWorkspaceId && selectableIds.has(file.selectedWorkspaceId)
? file.selectedWorkspaceId
: activeWorkspaceId
cachedWorkspaceFile = {
version: 1,
activeWorkspaceId,
selectedWorkspaceId,
workspaces: persistedWorkspaces
}
workspaceFileLoadedFromDisk = true
writeFileSync(getWorkspaceFilePath(), JSON.stringify(cachedWorkspaceFile, null, 2), {
encoding: 'utf-8',
mode: 0o600
})
}
function getLegacyWorkspace(): LinearWorkspace | null {
if (!hasStoredToken(LEGACY_WORKSPACE_ID)) {
return null
}
const viewer = getLegacyViewer()
return {
id: LEGACY_WORKSPACE_ID,
organizationId: viewer?.organizationId ?? LEGACY_WORKSPACE_ID,
organizationName: viewer?.organizationName ?? 'Saved Linear workspace',
organizationUrlKey: viewer?.organizationUrlKey,
displayName: viewer?.displayName ?? 'Linear API key',
email: viewer?.email ?? null,
isLegacy: true
}
}
function getWorkspaceState(): LinearWorkspaceFile {
const file = getWorkspaceFile()
const legacyWorkspace = getLegacyWorkspace()
const workspaces = [
...(legacyWorkspace ? [legacyWorkspace] : []),
...file.workspaces.filter((workspace) => hasStoredToken(workspace.id))
]
const activeWorkspaceId =
file.activeWorkspaceId &&
workspaces.some((workspace) => workspace.id === file.activeWorkspaceId)
? file.activeWorkspaceId
: (workspaces[0]?.id ?? null)
const selectedWorkspaceId =
file.selectedWorkspaceId === 'all'
? 'all'
: file.selectedWorkspaceId &&
workspaces.some((workspace) => workspace.id === file.selectedWorkspaceId)
? file.selectedWorkspaceId
: activeWorkspaceId
return {
version: 1,
activeWorkspaceId,
selectedWorkspaceId,
workspaces
}
}
function clearLegacyViewerOnDisk(): void {
try {
unlinkSync(getLegacyViewerPath())
} catch {
// File may not exist — safe to ignore.
}
}
function writeEncryptedToken(path: string, apiKey: string): void {
if (safeStorage.isEncryptionAvailable()) {
const encrypted = safeStorage.encryptString(apiKey)
writeFileSync(path, encrypted, { mode: 0o600 })
return
}
console.warn('[linear] safeStorage encryption unavailable — storing token in plaintext')
writeFileSync(path, apiKey, { encoding: 'utf-8', mode: 0o600 })
}
function saveWorkspaceToken(workspaceId: string, apiKey: string): void {
ensureOrcaDir()
if (workspaceId !== LEGACY_WORKSPACE_ID) {
ensureWorkspaceTokenDir()
}
const tokenPath = getWorkspaceTokenPath(workspaceId)
writeEncryptedToken(tokenPath, apiKey)
cachedTokens.set(workspaceId, apiKey)
credentialErrors.delete(workspaceId)
}
// Backward-compatible export for the legacy single-workspace storage path.
export function saveToken(apiKey: string): void {
saveWorkspaceToken(LEGACY_WORKSPACE_ID, apiKey)
}
export function loadToken(options: { force?: boolean; workspaceId?: string } = {}): string | null {
const workspaceId = options.workspaceId ?? resolveWorkspaceId()
if (!workspaceId) {
return null
}
const cached = cachedTokens.get(workspaceId)
if (cached !== undefined) {
return cached
}
if (!options.force) {
return null
}
const tokenPath = getWorkspaceTokenPath(workspaceId)
if (!existsSync(tokenPath)) {
return null
}
try {
const raw = readFileSync(tokenPath)
const token = readStoredCredentialToken('Linear', raw)
if (token) {
cachedTokens.set(workspaceId, token)
}
credentialErrors.delete(workspaceId)
return token
} catch (error) {
if (error instanceof CredentialDecryptionError) {
credentialErrors.set(workspaceId, error.message)
throw error
}
return null
}
}
export function hasStoredToken(workspaceId?: string): boolean {
if (!workspaceId) {
return getWorkspaceState().workspaces.length > 0
}
if (cachedTokens.has(workspaceId)) {
return true
}
return credentialFileHasContent(getWorkspaceTokenPath(workspaceId))
}
function clearTokenFile(workspaceId: string): void {
cachedTokens.delete(workspaceId)
credentialErrors.delete(workspaceId)
try {
unlinkSync(getWorkspaceTokenPath(workspaceId))
} catch {
// File may not exist — safe to ignore.
}
}
export function clearToken(workspaceId?: string): void {
if (!workspaceId) {
const state = getWorkspaceState()
for (const workspace of state.workspaces) {
clearTokenFile(workspace.id)
}
cachedTokens = new Map()
credentialErrors.clear()
cachedLegacyViewer = null
legacyViewerLoadedFromDisk = false
cachedWorkspaceFile = emptyWorkspaceFile()
workspaceFileLoadedFromDisk = true
clearLegacyViewerOnDisk()
writeWorkspaceFile(emptyWorkspaceFile())
return
}
clearTokenFile(workspaceId)
if (workspaceId === LEGACY_WORKSPACE_ID) {
cachedLegacyViewer = null
legacyViewerLoadedFromDisk = false
clearLegacyViewerOnDisk()
return
}
const file = getWorkspaceFile()
const workspaces = file.workspaces.filter((workspace) => workspace.id !== workspaceId)
const activeWorkspaceId =
file.activeWorkspaceId === workspaceId ? (workspaces[0]?.id ?? null) : file.activeWorkspaceId
const selectedWorkspaceId =
file.selectedWorkspaceId === workspaceId ? activeWorkspaceId : file.selectedWorkspaceId
writeWorkspaceFile({
version: 1,
activeWorkspaceId,
selectedWorkspaceId,
workspaces
})
}
function workspaceFromLinearData(
me: { displayName: string; email?: string | null },
org: { id: string; name: string; urlKey?: string | null }
): LinearWorkspace {
return {
id: org.id,
organizationId: org.id,
organizationName: org.name,
organizationUrlKey: org.urlKey ?? undefined,
displayName: me.displayName,
email: me.email ?? null
}
}
function upsertWorkspace(workspace: LinearWorkspace, options: { select?: boolean } = {}): void {
const file = getWorkspaceFile()
const current = file.workspaces.find((entry) => entry.id === workspace.id)
const credentialRevision = (current?.credentialRevision ?? 0) + 1
const workspaceWithRevision = { ...workspace, credentialRevision }
const withoutCurrent = file.workspaces.filter((entry) => entry.id !== workspace.id)
const workspaces = [...withoutCurrent, workspaceWithRevision].sort((a, b) =>
a.organizationName.localeCompare(b.organizationName)
)
const selectedWorkspaceId = options.select
? workspace.id
: file.selectedWorkspaceId && file.selectedWorkspaceId !== LEGACY_WORKSPACE_ID
? file.selectedWorkspaceId
: workspace.id
writeWorkspaceFile({
version: 1,
activeWorkspaceId: workspace.id,
selectedWorkspaceId,
workspaces
})
}
function replaceLegacyWorkspace(workspace: LinearWorkspace, token: string): void {
saveWorkspaceToken(workspace.id, token)
clearTokenFile(LEGACY_WORKSPACE_ID)
clearLegacyViewerOnDisk()
cachedLegacyViewer = null
legacyViewerLoadedFromDisk = true
upsertWorkspace(workspace, { select: true })
}
function resolveWorkspaceId(workspaceId?: string | null): string | null {
if (workspaceId && workspaceId !== 'all') {
return workspaceId
}
const state = getWorkspaceState()
if (
state.selectedWorkspaceId &&
state.selectedWorkspaceId !== 'all' &&
state.workspaces.some((workspace) => workspace.id === state.selectedWorkspaceId)
) {
return state.selectedWorkspaceId
}
if (
state.activeWorkspaceId &&
state.workspaces.some((workspace) => workspace.id === state.activeWorkspaceId)
) {
return state.activeWorkspaceId
}
return state.workspaces[0]?.id ?? null
}
// ── Client factory ───────────────────────────────────────────────────
// Why: issues/teams modules call this for real Linear actions — at that point
// decrypting the token and surfacing a keychain prompt is expected.
export function getClient(workspaceId?: string | null): LinearClient | null {
const token = loadToken({
force: true,
workspaceId: resolveWorkspaceId(workspaceId) ?? undefined
})
if (!token) {
return null
}
return new (loadLinearSdk().LinearClient)({ apiKey: token })
}
export function getClients(
workspaceId?: LinearWorkspaceSelection | null
): LinearClientForWorkspace[] {
const state = getWorkspaceState()
const isAllSelection = workspaceId === 'all'
const selectedWorkspaces = isAllSelection
? state.workspaces
: state.workspaces.filter((workspace) => workspace.id === resolveWorkspaceId(workspaceId))
const clients: LinearClientForWorkspace[] = []
for (const workspace of selectedWorkspaces) {
let token: string | null
try {
token = loadToken({ force: true, workspaceId: workspace.id })
} catch (error) {
// Why: under an 'all' selection one un-decryptable workspace must not
// collapse reads for the healthy ones. loadToken already recorded the
// per-workspace credentialError for getStatus to surface, so skip this
// workspace like a missing token. A specific-workspace selection still
// rethrows so the renderer can surface the decrypt banner promptly.
if (isAllSelection && error instanceof CredentialDecryptionError) {
continue
}
throw error
}
if (!token) {
continue
}
clients.push({
workspace,
client: new (loadLinearSdk().LinearClient)({ apiKey: token }),
apiKey: token
})
}
return clients
}
export function getPublicFileUrlClient(entry: LinearClientForWorkspace): LinearClient {
return new (loadLinearSdk().LinearClient)({
apiKey: entry.apiKey,
headers: {
'public-file-urls-expire-in': String(LINEAR_PUBLIC_FILE_URL_EXPIRY_SECONDS)
}
})
}
// ── Auth error detection ─────────────────────────────────────────────
// Why: 401 errors must trigger token clearing and a re-auth prompt in the
// renderer. All other errors are swallowed with console.warn to match GitHub
// client's graceful degradation.
export function isAuthError(error: unknown): boolean {
return error instanceof loadLinearSdk().AuthenticationLinearError
}
// ── Connect / disconnect / status ────────────────────────────────────
export async function connect(
apiKey: string
): Promise<
{ ok: true; viewer: LinearViewer; workspace: LinearWorkspace } | { ok: false; error: string }
> {
try {
const client = new (loadLinearSdk().LinearClient)({ apiKey })
const me = await client.viewer
const org = await me.organization
const workspace = workspaceFromLinearData(me, org)
saveWorkspaceToken(workspace.id, apiKey)
const legacyWorkspace = getLegacyWorkspace()
if (
legacyWorkspace &&
legacyWorkspace.organizationName === workspace.organizationName &&
legacyWorkspace.email === workspace.email
) {
clearTokenFile(LEGACY_WORKSPACE_ID)
clearLegacyViewerOnDisk()
cachedLegacyViewer = null
legacyViewerLoadedFromDisk = true
}
upsertWorkspace(workspace, { select: true })
return { ok: true, viewer: workspace, workspace }
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to validate API key'
return { ok: false, error: message }
}
}
export function disconnect(workspaceId?: string): void {
clearToken(workspaceId)
}
export function selectWorkspace(workspaceId: LinearWorkspaceSelection): LinearConnectionStatus {
const state = getWorkspaceState()
if (
workspaceId !== 'all' &&
!state.workspaces.some((workspace) => workspace.id === workspaceId)
) {
return getStatus()
}
const file = getWorkspaceFile()
writeWorkspaceFile({
version: 1,
activeWorkspaceId: workspaceId === 'all' ? file.activeWorkspaceId : workspaceId,
selectedWorkspaceId: workspaceId,
workspaces: file.workspaces
})
return getStatus()
}
export function getStatus(): LinearConnectionStatus {
const state = getWorkspaceState()
const selectedWorkspace =
state.selectedWorkspaceId && state.selectedWorkspaceId !== 'all'
? state.workspaces.find((workspace) => workspace.id === state.selectedWorkspaceId)
: null
const activeWorkspace =
selectedWorkspace ??
state.workspaces.find((workspace) => workspace.id === state.activeWorkspaceId) ??
state.workspaces[0] ??
null
const credentialError = state.workspaces
.map((workspace) => credentialErrors.get(workspace.id))
.find((message) => message !== undefined)
return {
connected: state.workspaces.length > 0,
viewer: activeWorkspace,
workspaces: state.workspaces,
activeWorkspaceId: state.activeWorkspaceId,
selectedWorkspaceId: state.selectedWorkspaceId,
...(credentialError ? { credentialError } : {})
}
}
export async function testConnection(
workspaceId?: string
): Promise<
{ ok: true; viewer: LinearViewer; workspace: LinearWorkspace } | { ok: false; error: string }
> {
const resolvedWorkspaceId = resolveWorkspaceId(workspaceId)
if (!resolvedWorkspaceId) {
return { ok: false, error: 'No API key stored.' }
}
let token: string | null
try {
token = loadToken({ force: true, workspaceId: resolvedWorkspaceId })
} catch (error) {
const message = error instanceof Error ? error.message : 'Test failed'
return { ok: false, error: message }
}
if (!token) {
return { ok: false, error: 'No API key stored.' }
}
try {
const client = new (loadLinearSdk().LinearClient)({ apiKey: token })
const me = await client.viewer
const org = await me.organization
const workspace = workspaceFromLinearData(me, org)
if (resolvedWorkspaceId === LEGACY_WORKSPACE_ID) {
replaceLegacyWorkspace(workspace, token)
} else {
saveWorkspaceToken(workspace.id, token)
upsertWorkspace(workspace, { select: true })
}
return { ok: true, viewer: workspace, workspace }
} catch (error) {
if (isAuthError(error)) {
clearToken(resolvedWorkspaceId)
}
const message = error instanceof Error ? error.message : 'Test failed'
return { ok: false, error: message }
}
}
// Why: called at main-process startup. We warm plaintext metadata only; tokens
// stay encrypted on disk until a user performs an actual Linear action.
export function initLinearToken(): void {
getWorkspaceFile()
getLegacyViewer()
}