cyrus-ai
Version:
AI-powered Linear issue automation using Claude
224 lines • 10.1 kB
JavaScript
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { LinearClient } from "@linear/sdk";
import { DEFAULT_CONFIG_FILENAME, migrateEdgeConfig, } from "cyrus-core";
import Fastify from "fastify";
import open from "open";
import { BaseCommand } from "./ICommand.js";
/**
* Self-auth-linear command - authenticate with Linear OAuth directly from CLI
* Handles the complete OAuth flow without requiring EdgeWorker
*/
export class SelfAuthCommand extends BaseCommand {
server = null;
callbackPort = parseInt(process.env.CYRUS_SERVER_PORT || "3456", 10);
async execute(_args) {
console.log("\nCyrus Linear Self-Authentication");
this.logDivider();
// Check required environment variables
const clientId = process.env.LINEAR_CLIENT_ID;
const clientSecret = process.env.LINEAR_CLIENT_SECRET;
const baseUrl = process.env.CYRUS_BASE_URL;
if (!clientId || !clientSecret || !baseUrl) {
this.logError("Missing required environment variables:");
if (!clientId)
console.log(" - LINEAR_CLIENT_ID");
if (!clientSecret)
console.log(" - LINEAR_CLIENT_SECRET");
if (!baseUrl)
console.log(" - CYRUS_BASE_URL");
console.log(`\nAdd these to your env file (${this.app.cyrusHome}/.env):`);
console.log(" LINEAR_CLIENT_ID=your-client-id");
console.log(" LINEAR_CLIENT_SECRET=your-client-secret");
console.log(" CYRUS_BASE_URL=https://your-tunnel-domain.com");
process.exit(1);
}
// Check config file exists
const configPath = resolve(this.app.cyrusHome, DEFAULT_CONFIG_FILENAME);
let config;
try {
config = migrateEdgeConfig(JSON.parse(readFileSync(configPath, "utf-8")));
}
catch {
this.logError(`Config file not found: ${configPath}`);
console.log("Run 'cyrus' first to create initial configuration.");
process.exit(1);
}
console.log("Configuration:");
console.log(` Client ID: ${clientId.substring(0, 20)}...`);
console.log(` Base URL: ${baseUrl}`);
console.log(` Config: ${configPath}`);
console.log(` Callback port: ${this.callbackPort}`);
console.log();
try {
if (process.env.CLOUDFLARE_TOKEN) {
this.logger.info("Starting cloudflare tunnel...");
const { SharedApplicationServer } = await import("cyrus-edge-worker");
const sharedApplicationServer = new SharedApplicationServer(this.callbackPort, baseUrl, false);
await sharedApplicationServer.startCloudflareTunnel(process.env.CLOUDFLARE_TOKEN);
}
// Start temporary server to receive OAuth callback
const authCode = await this.waitForCallback(clientId);
// Exchange code for tokens
console.log("Exchanging code for tokens...");
const tokens = await this.exchangeCodeForTokens(authCode, clientId, clientSecret);
this.logSuccess(`Got access token: ${tokens.accessToken.substring(0, 30)}...`);
// Fetch workspace info
console.log("Fetching workspace info...");
const workspace = await this.fetchWorkspaceInfo(tokens.accessToken);
this.logSuccess(`Workspace: ${workspace.name} (${workspace.id})`);
// Save workspace credentials to config.json
console.log("Saving tokens to config.json...");
if (!config.linearWorkspaces) {
config.linearWorkspaces = {};
}
config.linearWorkspaces[workspace.id] = {
linearToken: tokens.accessToken,
...(tokens.refreshToken
? { linearRefreshToken: tokens.refreshToken }
: {}),
linearWorkspaceName: workspace.name,
linearWorkspaceSlug: workspace.slug,
};
writeFileSync(configPath, JSON.stringify(config, null, "\t"), "utf-8");
this.logSuccess(`Saved credentials for workspace: ${workspace.name}`);
if (!config.repositories || config.repositories.length === 0) {
console.log(" No repositories configured yet. Run 'cyrus self-add-repo' to add one.");
}
console.log();
this.logSuccess("Authentication complete! Restart cyrus to use the new tokens.");
process.exit(0);
}
catch (error) {
this.logError(`Authentication failed: ${error.message}`);
process.exit(1);
}
finally {
// One of the key guarantees of finally — it runs regardless of how the try block exits (return, throw, or normal completion).
await this.cleanup();
}
}
async waitForCallback(clientId) {
return new Promise((resolve, reject) => {
const baseUrl = process.env.CYRUS_BASE_URL;
if (!baseUrl) {
reject(new Error("CYRUS_BASE_URL environment variable is required"));
return;
}
const redirectUri = `${baseUrl}/callback`;
// https://linear.app/developers/oauth-2-0-authentication
// https://linear.app/developers/oauth-actor-authorization
const oauthUrl = `https://linear.app/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=write,app:assignable,app:mentionable&actor=app`;
this.server = Fastify({ logger: false });
this.server.get("/callback", async (request, reply) => {
const query = request.query;
const code = query.code;
const error = query.error;
if (error) {
reply
.type("text/html; charset=utf-8")
.code(400)
.send(`<!DOCTYPE html>
<html><head><meta charset="utf-8"></head>
<body style="font-family: system-ui; padding: 40px; text-align: center;">
<h2>Authorization failed</h2>
<p>${error}</p>
</body></html>`);
reject(new Error(`OAuth error: ${error}`));
return;
}
if (code) {
reply
.type("text/html; charset=utf-8")
.code(200)
.send(`<!DOCTYPE html>
<html><head><meta charset="utf-8"></head>
<body style="font-family: system-ui; padding: 40px; text-align: center;">
<h2>Cyrus authorized successfully</h2>
<p>You can close this window and return to the terminal.</p>
</body></html>`);
resolve(code);
return;
}
reply
.type("text/html; charset=utf-8")
.code(400)
.send(`<!DOCTYPE html>
<html><head><meta charset="utf-8"></head>
<body style="font-family: system-ui; padding: 40px; text-align: center;">
<h2>Missing authorization code</h2>
</body></html>`);
reject(new Error("Missing authorization code"));
});
const isExternalHost = process.env.CYRUS_HOST_EXTERNAL?.toLowerCase().trim() === "true";
const listenHost = isExternalHost ? "0.0.0.0" : "localhost";
this.server
.listen({ port: this.callbackPort, host: listenHost })
.then(() => {
console.log(`Waiting for authorization on port ${this.callbackPort}...`);
console.log();
console.log("Opening browser for Linear authorization...");
console.log();
console.log("If browser doesn't open, visit:");
console.log(oauthUrl);
console.log();
open(oauthUrl).catch(() => {
console.log("Could not open browser automatically.");
});
})
.catch((err) => {
reject(new Error(`Server error: ${err.message}`));
});
});
}
async exchangeCodeForTokens(code, clientId, clientSecret) {
const baseUrl = process.env.CYRUS_BASE_URL;
const redirectUri = `${baseUrl}/callback`;
// https://linear.app/developers/oauth-2-0-authentication
const response = await fetch("https://api.linear.app/oauth/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
code,
redirect_uri: redirectUri,
client_id: clientId,
client_secret: clientSecret,
grant_type: "authorization_code",
}).toString(),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Token exchange failed: ${errorText}`);
}
const data = (await response.json());
if (!data.access_token || !data.access_token.startsWith("lin_oauth_")) {
throw new Error("Invalid access token received");
}
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
};
}
async fetchWorkspaceInfo(accessToken) {
const linearClient = new LinearClient({ accessToken });
const viewer = await linearClient.viewer;
const organization = await viewer.organization;
if (!organization?.id) {
throw new Error("Failed to get workspace info from Linear");
}
return {
id: organization.id,
name: organization.name || organization.id,
slug: organization.urlKey,
};
}
async cleanup() {
if (this.server) {
await this.server.close();
this.server = null;
}
}
}
//# sourceMappingURL=SelfAuthCommand.js.map