First version
This commit is contained in:
+234
@@ -0,0 +1,234 @@
|
||||
//
|
||||
// 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,
|
||||
};
|
||||
Reference in New Issue
Block a user