235 lines
7.2 KiB
JavaScript
235 lines
7.2 KiB
JavaScript
//
|
|
// auth.js — account status and sign-in, delegated to the Claude Code CLI.
|
|
//
|
|
// The extension never implements the OAuth flow itself: `claude auth login`
|
|
// runs Anthropic's own flow and stores the credentials where Claude Code
|
|
// expects them. We drive that command, surface the URL, and relay the code.
|
|
//
|
|
|
|
const { log, warn, runCommand, processWriter } = require("./util.js");
|
|
const cli = require("./cli.js");
|
|
|
|
const URL_PATTERN = new RegExp("https://[^\\s\"'<>]+", "g");
|
|
const LOGIN_URL_TIMEOUT = 20000;
|
|
|
|
/**
|
|
* Current account status.
|
|
* Returns { available, loggedIn, authMethod, email, subscriptionType, orgName, error }.
|
|
*/
|
|
async function status() {
|
|
let binary;
|
|
try {
|
|
binary = await cli.resolveBinary();
|
|
} catch (err) {
|
|
return { available: false, loggedIn: false, error: String(err.message || err) };
|
|
}
|
|
|
|
const result = await runCommand(binary, ["auth", "status", "--json"], {
|
|
env: cli.environment(),
|
|
timeout: 15000,
|
|
});
|
|
|
|
if (result.status !== 0 && !result.stdout.trim()) {
|
|
return {
|
|
available: true,
|
|
loggedIn: false,
|
|
error: result.stderr.trim() || `\`claude auth status\` exited with ${result.status}`,
|
|
};
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(result.stdout.trim());
|
|
return Object.assign({ available: true, usingStoredKey: !!cli.storedApiKey() }, parsed);
|
|
} catch (err) {
|
|
return {
|
|
available: true,
|
|
loggedIn: false,
|
|
error: "Could not read the account status returned by Claude Code.",
|
|
};
|
|
}
|
|
}
|
|
|
|
/** A short line for the sidebar, e.g. "[email protected] · Pro". */
|
|
function describe(state) {
|
|
if (!state) return "Checking…";
|
|
if (!state.available) return "Claude Code not found";
|
|
if (state.error && !state.loggedIn) return "Signed out";
|
|
if (!state.loggedIn) return "Signed out";
|
|
|
|
const parts = [];
|
|
if (state.email) parts.push(state.email);
|
|
else if (state.authMethod === "apiKey") parts.push("API key");
|
|
|
|
if (state.subscriptionType) {
|
|
parts.push(state.subscriptionType.charAt(0).toUpperCase() + state.subscriptionType.slice(1));
|
|
} else if (state.authMethod) {
|
|
parts.push(state.authMethod);
|
|
}
|
|
|
|
return parts.join(" · ") || "Signed in";
|
|
}
|
|
|
|
/**
|
|
* Run `claude auth login`, opening the authorization URL in the browser and
|
|
* relaying the pasted code back to the CLI.
|
|
*
|
|
* Resolves true when the CLI exits successfully.
|
|
*/
|
|
async function signIn(options = {}) {
|
|
const binary = await cli.resolveBinary();
|
|
const args = ["auth", "login", options.console ? "--console" : "--claudeai"];
|
|
|
|
const process = new Process(binary, {
|
|
args: args,
|
|
cwd: nova.workspace.path || nova.environment["HOME"],
|
|
env: cli.environment(),
|
|
stdio: "pipe",
|
|
});
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let writer = null;
|
|
let sawURL = false;
|
|
let finished = false;
|
|
let output = "";
|
|
|
|
const finish = (fn, value) => {
|
|
if (finished) return;
|
|
finished = true;
|
|
clearTimeout(urlTimer);
|
|
fn(value);
|
|
};
|
|
|
|
const handleLine = (line) => {
|
|
output += line;
|
|
log("auth login:", line.trim());
|
|
if (sawURL) return;
|
|
|
|
const matches = String(line).match(URL_PATTERN);
|
|
if (!matches || matches.length === 0) return;
|
|
|
|
const url = matches[0].replace(/[.,)\]]+$/, "");
|
|
if (!/oauth|authorize|login/i.test(url)) return;
|
|
|
|
sawURL = true;
|
|
nova.openURL(url);
|
|
promptForCode(url);
|
|
};
|
|
|
|
const promptForCode = (url) => {
|
|
nova.workspace.showInputPanel(
|
|
"Finish signing in to Claude in your browser, then paste the code it gives you.",
|
|
{
|
|
label: "Authorization code",
|
|
placeholder: "Paste code here",
|
|
prompt: "Sign In",
|
|
},
|
|
(value) => {
|
|
if (value === null) {
|
|
try {
|
|
process.terminate();
|
|
} catch (_) {
|
|
/* already gone */
|
|
}
|
|
finish(resolve, false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (!writer) writer = processWriter(process);
|
|
writer.write(value.trim() + "\n");
|
|
} catch (err) {
|
|
finish(reject, new Error("Could not send the code to Claude Code: " + err));
|
|
}
|
|
}
|
|
);
|
|
};
|
|
|
|
const urlTimer = setTimeout(() => {
|
|
if (sawURL || finished) return;
|
|
try {
|
|
process.terminate();
|
|
} catch (_) {
|
|
/* already gone */
|
|
}
|
|
finish(
|
|
reject,
|
|
new Error(
|
|
"Claude Code did not offer a sign-in link. Run `claude auth login` in a terminal to sign in."
|
|
)
|
|
);
|
|
}, LOGIN_URL_TIMEOUT);
|
|
|
|
process.onStdout(handleLine);
|
|
process.onStderr(handleLine);
|
|
process.onDidExit((code) => {
|
|
if (code === 0) {
|
|
finish(resolve, true);
|
|
} else {
|
|
finish(
|
|
reject,
|
|
new Error(
|
|
(output.trim().split("\n").pop() || "Sign-in failed.") +
|
|
` (claude auth login exited with ${code})`
|
|
)
|
|
);
|
|
}
|
|
});
|
|
|
|
try {
|
|
process.start();
|
|
} catch (err) {
|
|
finish(reject, err);
|
|
}
|
|
});
|
|
}
|
|
|
|
async function signOut() {
|
|
const binary = await cli.resolveBinary();
|
|
const result = await runCommand(binary, ["auth", "logout"], {
|
|
env: cli.environment(),
|
|
timeout: 15000,
|
|
});
|
|
if (result.status !== 0) {
|
|
throw new Error(result.stderr.trim() || `\`claude auth logout\` exited with ${result.status}`);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Fallback path: open a terminal window running `claude auth login`, for
|
|
* environments where the piped flow cannot complete (SSO, device prompts).
|
|
*/
|
|
async function signInViaTerminal() {
|
|
const binary = await cli.resolveBinary();
|
|
const dir = nova.extension.globalStoragePath;
|
|
|
|
try {
|
|
nova.fs.mkdir(dir);
|
|
} catch (_) {
|
|
/* already exists */
|
|
}
|
|
|
|
const scriptPath = nova.path.join(dir, "sign-in-to-claude.command");
|
|
const file = nova.fs.open(scriptPath, "w");
|
|
file.write("#!/bin/sh\n");
|
|
file.write("# Opened by the Claude extension for Nova.\n");
|
|
file.write(`exec ${JSON.stringify(binary)} auth login\n`);
|
|
file.close();
|
|
|
|
await runCommand("/bin/chmod", ["+x", scriptPath], { timeout: 5000 });
|
|
const result = await runCommand("/usr/bin/open", [scriptPath], { timeout: 5000 });
|
|
if (result.status !== 0) {
|
|
warn("could not open a terminal:", result.stderr);
|
|
throw new Error("Could not open a terminal window. Run `claude auth login` yourself to sign in.");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
module.exports = {
|
|
status,
|
|
describe,
|
|
signIn,
|
|
signOut,
|
|
signInViaTerminal,
|
|
};
|