UNPKG

@stackmemoryai/stackmemory

Version:

Project-scoped memory for AI coding tools. Durable context across sessions with MCP integration, frames, smart retrieval, Claude Code skills, and automatic hooks.

338 lines (337 loc) 10.2 kB
import { fileURLToPath as __fileURLToPath } from 'url'; import { dirname as __pathDirname } from 'path'; const __filename = __fileURLToPath(import.meta.url); const __dirname = __pathDirname(__filename); import { createHash, randomBytes } from "crypto"; import { readFileSync, writeFileSync, existsSync } from "fs"; import { join } from "path"; import { logger } from "../../core/monitoring/logger.js"; import { IntegrationError, ErrorCode } from "../../core/errors/index.js"; class LinearAuthManager { configPath; tokensPath; config; constructor(projectRoot) { const configDir = join(projectRoot, ".stackmemory"); this.configPath = join(configDir, "linear-config.json"); this.tokensPath = join(configDir, "linear-tokens.json"); } /** * Check if Linear integration is configured */ isConfigured() { return existsSync(this.configPath) && existsSync(this.tokensPath); } /** * Save OAuth application configuration */ saveConfig(config) { writeFileSync(this.configPath, JSON.stringify(config, null, 2)); this.config = config; logger.info("Linear OAuth configuration saved"); } /** * Load OAuth configuration */ loadConfig() { if (!existsSync(this.configPath)) { return null; } try { const configData = readFileSync(this.configPath, "utf8"); this.config = JSON.parse(configData); return this.config; } catch (error) { logger.error("Failed to load Linear configuration:", error); return null; } } /** * Generate OAuth authorization URL with PKCE */ generateAuthUrl(state) { if (!this.config) { throw new IntegrationError( "Linear OAuth configuration not loaded", ErrorCode.LINEAR_AUTH_FAILED ); } const codeVerifier = randomBytes(32).toString("base64url"); const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url"); const params = new URLSearchParams({ client_id: this.config.clientId, redirect_uri: this.config.redirectUri, response_type: "code", scope: this.config.scopes.join(" "), code_challenge: codeChallenge, code_challenge_method: "S256", actor: "app" // Enable actor authorization for service accounts }); if (state) { params.set("state", state); } const authUrl = `https://linear.app/oauth/authorize?${params.toString()}`; return { url: authUrl, codeVerifier }; } /** * Exchange authorization code for access token */ async exchangeCodeForToken(authCode, codeVerifier) { if (!this.config) { throw new IntegrationError( "Linear OAuth configuration not loaded", ErrorCode.LINEAR_AUTH_FAILED ); } const tokenUrl = "https://api.linear.app/oauth/token"; const body = new URLSearchParams({ grant_type: "authorization_code", client_id: this.config.clientId, client_secret: this.config.clientSecret, redirect_uri: this.config.redirectUri, code: authCode, code_verifier: codeVerifier }); const response = await fetch(tokenUrl, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, body: body.toString() }); if (!response.ok) { const errorText = await response.text(); throw new IntegrationError( `Token exchange failed: ${response.status}`, ErrorCode.LINEAR_AUTH_FAILED, { status: response.status, body: errorText } ); } const result = await response.json(); const expiresAt = Date.now() + result.expiresIn * 1e3; const tokens = { accessToken: result.accessToken, refreshToken: result.refreshToken, expiresAt, scope: result.scope.split(" ") }; this.saveTokens(tokens); return tokens; } /** * Refresh access token using refresh token */ async refreshAccessToken() { if (!this.config) { throw new IntegrationError( "Linear OAuth configuration not loaded", ErrorCode.LINEAR_AUTH_FAILED ); } const currentTokens = this.loadTokens(); if (!currentTokens?.refreshToken) { throw new IntegrationError( "No refresh token available", ErrorCode.LINEAR_AUTH_FAILED ); } const tokenUrl = "https://api.linear.app/oauth/token"; const body = new URLSearchParams({ grant_type: "refresh_token", client_id: this.config.clientId, client_secret: this.config.clientSecret, refresh_token: currentTokens.refreshToken }); const response = await fetch(tokenUrl, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, body: body.toString() }); if (!response.ok) { const errorText = await response.text(); throw new IntegrationError( `Token refresh failed: ${response.status}`, ErrorCode.LINEAR_AUTH_FAILED, { status: response.status, body: errorText } ); } const result = await response.json(); const tokens = { accessToken: result.accessToken, refreshToken: result.refreshToken || currentTokens.refreshToken, expiresAt: Date.now() + result.expiresIn * 1e3, scope: result.scope.split(" ") }; this.saveTokens(tokens); return tokens; } /** * Get valid access token (refresh if needed) */ async getValidToken() { const tokens = this.loadTokens(); if (!tokens) { throw new IntegrationError( "No Linear tokens found. Please complete OAuth setup.", ErrorCode.LINEAR_AUTH_FAILED ); } const fiveMinutes = 5 * 60 * 1e3; if (tokens.expiresAt - Date.now() < fiveMinutes) { logger.info("Linear token expiring soon, refreshing..."); const newTokens = await this.refreshAccessToken(); return newTokens.accessToken; } return tokens.accessToken; } /** * Save tokens to file */ saveTokens(tokens) { writeFileSync(this.tokensPath, JSON.stringify(tokens, null, 2)); logger.info("Linear tokens saved"); } /** * Load tokens from file */ loadTokens() { if (!existsSync(this.tokensPath)) { return null; } try { const tokensData = readFileSync(this.tokensPath, "utf8"); return JSON.parse(tokensData); } catch (error) { logger.error("Failed to load Linear tokens:", error); return null; } } /** * Clear stored tokens and config */ clearAuth() { if (existsSync(this.tokensPath)) { writeFileSync(this.tokensPath, ""); } if (existsSync(this.configPath)) { writeFileSync(this.configPath, ""); } logger.info("Linear authentication cleared"); } } const DEFAULT_LINEAR_SCOPES = [ "read", // Read issues, projects, teams "write", // Create and update issues "admin" // Manage team settings and workflows ]; class LinearOAuthSetup { authManager; constructor(projectRoot) { this.authManager = new LinearAuthManager(projectRoot); } /** * Interactive setup for Linear OAuth */ async setupInteractive() { const config = { clientId: process.env["LINEAR_CLIENT_ID"] || "", clientSecret: process.env["LINEAR_CLIENT_SECRET"] || "", redirectUri: process.env["LINEAR_REDIRECT_URI"] || "http://localhost:3456/auth/linear/callback", scopes: DEFAULT_LINEAR_SCOPES }; if (!config.clientId || !config.clientSecret) { return { authUrl: "", instructions: [ "1. Create a Linear OAuth application at https://linear.app/settings/api", "2. Set redirect URI to: http://localhost:3456/auth/linear/callback", "3. Copy your Client ID and Client Secret", "4. Set environment variables:", ' export LINEAR_CLIENT_ID="your_client_id"', ' export LINEAR_CLIENT_SECRET="your_client_secret"', "5. Re-run this setup command" ] }; } this.authManager.saveConfig(config); const { url, codeVerifier } = this.authManager.generateAuthUrl(); process.env["_LINEAR_CODE_VERIFIER"] = codeVerifier; return { authUrl: url, instructions: [ "1. Open this URL in your browser:", url, "", "2. Approve the StackMemory integration", "3. Copy the authorization code from the redirect URL", "4. Run: stackmemory linear authorize <code>" ] }; } /** * Complete OAuth flow with authorization code */ async completeAuth(authCode) { try { const codeVerifier = process.env["_LINEAR_CODE_VERIFIER"]; if (!codeVerifier) { throw new IntegrationError( "Code verifier not found. Please restart the setup process.", ErrorCode.LINEAR_AUTH_FAILED ); } await this.authManager.exchangeCodeForToken(authCode, codeVerifier); delete process.env["_LINEAR_CODE_VERIFIER"]; logger.info("Linear OAuth setup completed successfully!"); return true; } catch (error) { logger.error("Failed to complete Linear OAuth setup:", error); return false; } } /** * Test the Linear connection */ async testConnection() { try { const token = await this.authManager.getValidToken(); const response = await fetch("https://api.linear.app/graphql", { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: JSON.stringify({ query: "query { viewer { id name email } }" }) }); if (response.ok) { const result = await response.json(); if (result.data?.viewer) { logger.info( `Connected to Linear as: ${result.data.viewer.name} (${result.data.viewer.email})` ); return true; } } return false; } catch (error) { logger.error("Linear connection test failed:", error); return false; } } } export { DEFAULT_LINEAR_SCOPES, LinearAuthManager, LinearOAuthSetup }; //# sourceMappingURL=auth.js.map