import { Entry } from "@napi-rs/keyring"; import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { loadJiraConfig, type JiraConfig } from "./client.ts"; const KEYRING_SERVICE = "@lollyz/pi-jira-extension"; const KEYRING_ACCOUNT = "jira-api-token"; export type JiraPreferences = { baseUrl: string; email: string; projectKey?: string; defaultIssueType?: string; readOnly?: boolean; }; export interface JiraCredentialStore { getPassword(): string | null; setPassword(password: string): void; deletePassword(): void; } function credentialStore(): JiraCredentialStore { return new Entry(KEYRING_SERVICE, KEYRING_ACCOUNT); } export function jiraConfigPath(env: NodeJS.ProcessEnv = process.env): string { const agentDir = env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), ".pi", "agent"); return join(agentDir, "jira", "config.json"); } export function loadJiraPreferences(env: NodeJS.ProcessEnv = process.env): JiraPreferences | undefined { try { const parsed = JSON.parse(readFileSync(jiraConfigPath(env), "utf8")) as Partial; if (!parsed.baseUrl || !parsed.email) return undefined; return normalizePreferences(parsed as JiraPreferences); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw new Error(`Unable to read Jira configuration: ${error instanceof Error ? error.message : String(error)}`); } } function normalizePreferences(preferences: JiraPreferences): JiraPreferences { const baseUrl = preferences.baseUrl.trim().replace(/\/+$/, ""); const email = preferences.email.trim(); loadJiraConfig({ JIRA_BASE_URL: baseUrl, JIRA_EMAIL: email, JIRA_API_TOKEN: "validation-only" }); const projectKey = preferences.projectKey?.trim().toUpperCase() || undefined; if (projectKey && !/^[A-Z][A-Z0-9_]*$/.test(projectKey)) throw new Error(`Invalid Jira project key: ${projectKey}`); return { baseUrl, email, ...(projectKey ? { projectKey } : {}), ...(preferences.defaultIssueType?.trim() ? { defaultIssueType: preferences.defaultIssueType.trim() } : {}), ...(preferences.readOnly !== undefined ? { readOnly: preferences.readOnly } : {}), }; } export function saveJiraPreferences(preferences: JiraPreferences, env: NodeJS.ProcessEnv = process.env): void { const normalized = normalizePreferences(preferences); const path = jiraConfigPath(env); const directory = dirname(path); const temporaryPath = `${path}.${process.pid}.tmp`; mkdirSync(directory, { recursive: true, mode: 0o700 }); chmodSync(directory, 0o700); try { writeFileSync(temporaryPath, `${JSON.stringify(normalized, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" }); chmodSync(temporaryPath, 0o600); renameSync(temporaryPath, path); } finally { rmSync(temporaryPath, { force: true }); } } function isMissingCredentialError(error: unknown): boolean { const candidate = error as { code?: unknown; name?: unknown; message?: unknown }; return [candidate.code, candidate.name, candidate.message].some((value) => typeof value === "string" && /no.?entry|not.?found/i.test(value)); } export function loadJiraCredential(credentials: JiraCredentialStore = credentialStore()): string | undefined { try { return credentials.getPassword()?.trim() || undefined; } catch (error) { if (isMissingCredentialError(error)) return undefined; throw new Error(`Unable to read the Jira token from the keychain: ${error instanceof Error ? error.message : String(error)}`); } } export function loadEffectiveJiraConfig( env: NodeJS.ProcessEnv = process.env, credentials: JiraCredentialStore = credentialStore(), ): JiraConfig { const environmentBaseUrl = env.JIRA_BASE_URL?.trim(); const environmentEmail = env.JIRA_EMAIL?.trim(); const environmentToken = env.JIRA_API_TOKEN?.trim(); const completeEnvironment = Boolean(environmentBaseUrl && environmentEmail && environmentToken); let saved: JiraPreferences | undefined; try { saved = loadJiraPreferences(env); } catch (error) { if (environmentBaseUrl && environmentEmail) { const fallbackToken = environmentToken || loadJiraCredential(credentials); if (fallbackToken) return loadJiraConfig({ ...env, JIRA_API_TOKEN: fallbackToken }); } throw error; } // A completed /jira-setup is authoritative. Inherited environment variables can // otherwise keep an old token alive after the user has replaced it in the keychain. if (saved) { const keychainToken = loadJiraCredential(credentials); if (keychainToken) { return loadJiraConfig({ JIRA_BASE_URL: saved.baseUrl, JIRA_EMAIL: saved.email, JIRA_API_TOKEN: keychainToken, }); } } if (completeEnvironment) return loadJiraConfig(env); return loadJiraConfig({ ...env, JIRA_BASE_URL: environmentBaseUrl || saved?.baseUrl, JIRA_EMAIL: environmentEmail || saved?.email, JIRA_API_TOKEN: environmentToken || (saved ? undefined : loadJiraCredential(credentials)), }); } export function saveJiraCredential(token: string, credentials: JiraCredentialStore = credentialStore()): void { const normalized = token.trim(); if (!normalized) throw new Error("Missing Jira API token"); credentials.setPassword(normalized); } export function deleteJiraCredential(credentials: JiraCredentialStore = credentialStore()): void { try { credentials.deletePassword(); } catch (error) { if (!isMissingCredentialError(error)) throw error; } } export function saveJiraSetup( preferences: JiraPreferences, token: string, env: NodeJS.ProcessEnv = process.env, credentials: JiraCredentialStore = credentialStore(), ): void { const previousToken = loadJiraCredential(credentials); saveJiraCredential(token, credentials); try { saveJiraPreferences(preferences, env); } catch (error) { try { if (previousToken) saveJiraCredential(previousToken, credentials); else deleteJiraCredential(credentials); } catch (rollbackError) { throw new AggregateError([error, rollbackError], "Salvataggio Jira fallito e rollback del keychain non riuscito"); } throw error; } }