@stackmemoryai/stackmemory
Version:
Lossless, project-scoped memory for AI coding tools. Durable context across sessions with 56 MCP tools, FTS5 search, conductor orchestrator, loop/watch monitoring, snapshot capture, pre-flight overlap checks, Claude/Codex/OpenCode wrappers, Linear sync, a
337 lines (336 loc) • 10.2 kB
JavaScript
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
};