Skip to content

feat: add login and logout command#1

Open
alerizzo wants to merge 3 commits intomainfrom
feat-login
Open

feat: add login and logout command#1
alerizzo wants to merge 3 commits intomainfrom
feat-login

Conversation

@alerizzo
Copy link
Collaborator

Adds login and logout commands. Token is stored encrypted in the user's directory.

Copilot AI review requested due to automatic review settings March 12, 2026 17:52

This comment was marked as resolved.

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings March 12, 2026 18:05
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 8 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +106 to +113
try {
if (!fs.existsSync(CREDENTIALS_FILE)) {
return false;
}
fs.unlinkSync(CREDENTIALS_FILE);
return true;
} catch {
return false;
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deleteCredentials() returns false for any error (including permission/IO failures), which makes logout report “No stored credentials found” even when credentials exist but couldn’t be removed. Consider returning false only for the “file does not exist” case, and letting unexpected unlink errors propagate so handleError() can report them.

Suggested change
try {
if (!fs.existsSync(CREDENTIALS_FILE)) {
return false;
}
fs.unlinkSync(CREDENTIALS_FILE);
return true;
} catch {
return false;
if (!fs.existsSync(CREDENTIALS_FILE)) {
return false;
}
try {
fs.unlinkSync(CREDENTIALS_FILE);
return true;
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code === "ENOENT") {
// File was removed between the existence check and unlink
return false;
}
throw error;

Copilot uses AI. Check for mistakes.
Comment on lines +132 to +149
const c = char.toString();

if (c === "\n" || c === "\r") {
cleanup();
process.stdout.write("\n");
resolve(token);
} else if (c === "\u0003") {
cleanup();
process.stdout.write("\n");
process.exit(1);
} else if (c === "\u007f" || c === "\b") {
if (token.length > 0) {
token = token.slice(0, -1);
process.stdout.write("\b \b");
}
} else if (c >= " ") {
token += c;
process.stdout.write("*");
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

promptForToken’s onData handler treats the incoming char as a single character, but in raw mode paste/multi-byte input can arrive as a chunk containing multiple characters. In that case the whole chunk is appended to token while only one * is printed, and control-key detection won’t work correctly for embedded newlines. Iterate over the chunk’s characters and handle each one individually.

Suggested change
const c = char.toString();
if (c === "\n" || c === "\r") {
cleanup();
process.stdout.write("\n");
resolve(token);
} else if (c === "\u0003") {
cleanup();
process.stdout.write("\n");
process.exit(1);
} else if (c === "\u007f" || c === "\b") {
if (token.length > 0) {
token = token.slice(0, -1);
process.stdout.write("\b \b");
}
} else if (c >= " ") {
token += c;
process.stdout.write("*");
const chunk = char.toString();
for (const c of chunk) {
if (c === "\n" || c === "\r") {
cleanup();
process.stdout.write("\n");
resolve(token);
return;
} else if (c === "\u0003") {
cleanup();
process.stdout.write("\n");
process.exit(1);
return;
} else if (c === "\u007f" || c === "\b") {
if (token.length > 0) {
token = token.slice(0, -1);
process.stdout.write("\b \b");
}
} else if (c >= " ") {
token += c;
process.stdout.write("*");
}

Copilot uses AI. Check for mistakes.
Comment on lines +32 to +54
if (options.token) {
token = String(options.token).trim();

if (!token) {
console.error(ansis.red("Error: Token cannot be empty."));
process.exit(1);
}
} else {
console.log(ansis.bold("\nCodacy Login\n"));
console.log("You need an Account API Token to authenticate.");
console.log(
`Get one at: ${ansis.cyan("https://app.codacy.com/account/access-management")}`,
);
console.log(
ansis.dim(" My Account > Access Management > API Tokens\n"),
);

token = await promptForToken("API Token: ");

if (!token.trim()) {
console.error(ansis.red("Error: Token cannot be empty."));
process.exit(1);
}
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The login command uses process.exit(1) for validation failures (empty --token or empty interactive input). This bypasses Commander’s exitOverride() (used in tests and potentially by consumers embedding the CLI) and also bypasses the existing handleError() path used elsewhere. Prefer throwing an Error (or calling handleError) so exit behavior is consistent and overrideable.

Copilot uses AI. Check for mistakes.
Comment on lines +59 to +64
const spinner = ora("Validating token...").start();

OpenAPI.HEADERS = {
"api-token": token,
"X-Codacy-Origin": "cli-cloud-tool",
};
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

login sets OpenAPI.HEADERS directly, duplicating the header logic that now also exists in src/utils/auth.ts (including the X-Codacy-Origin value). Consider reusing a shared helper (e.g., export updateApiHeaders or introduce a small setApiToken(token) util) to avoid drift if headers/origin change later.

Copilot uses AI. Check for mistakes.
Comment on lines +14 to +26
vi.mock("node:os", async () => {
const actual = await vi.importActual<typeof import("node:os")>("node:os");
return {
...actual,
homedir: () => os.tmpdir().replace(/\/$/, "") + "/.codacy-home-" + process.pid,
};
});

describe("credentials", () => {
const credentialsDir = path.join(
os.tmpdir().replace(/\/$/, "") + "/.codacy-home-" + process.pid,
".codacy",
);
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test’s mocked homedir and credentialsDir build paths by concatenating strings with / and trimming only a trailing forward slash. This is brittle on Windows (backslashes, drive letters) and can produce mixed separators. Prefer using path.join(os.tmpdir(), .codacy-home-${process.pid}) (and reuse that helper in both places) so the tests are cross-platform.

Suggested change
vi.mock("node:os", async () => {
const actual = await vi.importActual<typeof import("node:os")>("node:os");
return {
...actual,
homedir: () => os.tmpdir().replace(/\/$/, "") + "/.codacy-home-" + process.pid,
};
});
describe("credentials", () => {
const credentialsDir = path.join(
os.tmpdir().replace(/\/$/, "") + "/.codacy-home-" + process.pid,
".codacy",
);
const mockHomeDir = path.join(os.tmpdir(), `.codacy-home-${process.pid}`);
vi.mock("node:os", async () => {
const actual = await vi.importActual<typeof import("node:os")>("node:os");
return {
...actual,
homedir: () => mockHomeDir,
};
});
describe("credentials", () => {
const credentialsDir = path.join(mockHomeDir, ".codacy");

Copilot uses AI. Check for mistakes.
throw new Error(
"Invalid API token. Check that it is correct and not expired.",
);
}
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The catch block treats any non-401 failure from AccountService.getUser() as “Could not reach the Codacy API / network connection”. If the API returns other HTTP statuses (e.g., 403, 429, 5xx), this message will be misleading. Consider distinguishing “received an error response” vs “network error”, or include the status code/message in the thrown error.

Suggested change
}
}
if (typeof apiErr?.status === "number") {
throw new Error(
`Codacy API returned an error (status ${apiErr.status}). Please try again or check your permissions.`,
);
}

Copilot uses AI. Check for mistakes.
Comment on lines +23 to +35
function getMachineKey(): string {
const info = os.userInfo();
return [os.hostname(), info.username, os.homedir(), os.platform()].join("|");
}

function deriveKey(machineKey: string, salt: Buffer): Buffer {
return crypto.pbkdf2Sync(
machineKey,
salt,
PBKDF2_ITERATIONS,
KEY_LENGTH,
PBKDF2_DIGEST,
);
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getMachineKey()/deriveKey() derive the encryption key purely from machine/user metadata (hostname, username, homedir, platform) plus a stored salt. Since this material isn’t secret, anyone who can read ~/.codacy/credentials and can reproduce those values can decrypt the token; this is closer to obfuscation than secure at-rest encryption. Consider using an OS keychain/credential store (Keychain/CredMan/libsecret) or a user-provided passphrase, or at minimum document this limitation clearly.

Copilot uses AI. Check for mistakes.
Comment on lines +94 to +100
try {
if (!fs.existsSync(CREDENTIALS_FILE)) {
return null;
}
const payload = fs.readFileSync(CREDENTIALS_FILE, "utf8");
return decryptToken(payload);
} catch {
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loadCredentials() catches all errors and returns null, which can mask actionable problems like permission errors (EACCES), a directory being a file, etc. It would be better to only swallow expected parse/decrypt failures and rethrow (or surface) filesystem/permission errors so the user gets a clear message instead of a generic “No API token found”.

Suggested change
try {
if (!fs.existsSync(CREDENTIALS_FILE)) {
return null;
}
const payload = fs.readFileSync(CREDENTIALS_FILE, "utf8");
return decryptToken(payload);
} catch {
if (!fs.existsSync(CREDENTIALS_FILE)) {
return null;
}
const payload = fs.readFileSync(CREDENTIALS_FILE, "utf8");
try {
return decryptToken(payload);
} catch {
// Treat invalid/corrupted credentials as "no credentials"

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants