Files
claude-nova/Scripts/auth.js
T
2026-08-11 12:38:02 -03:00

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,
};