Conversation
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
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.
| try { | ||
| if (!fs.existsSync(CREDENTIALS_FILE)) { | ||
| return false; | ||
| } | ||
| fs.unlinkSync(CREDENTIALS_FILE); | ||
| return true; | ||
| } catch { | ||
| return false; |
There was a problem hiding this comment.
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.
| 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; |
| 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("*"); |
There was a problem hiding this comment.
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.
| 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("*"); | |
| } |
| 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); | ||
| } |
There was a problem hiding this comment.
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.
| const spinner = ora("Validating token...").start(); | ||
|
|
||
| OpenAPI.HEADERS = { | ||
| "api-token": token, | ||
| "X-Codacy-Origin": "cli-cloud-tool", | ||
| }; |
There was a problem hiding this comment.
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.
| 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", | ||
| ); |
There was a problem hiding this comment.
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.
| 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"); |
| throw new Error( | ||
| "Invalid API token. Check that it is correct and not expired.", | ||
| ); | ||
| } |
There was a problem hiding this comment.
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.
| } | |
| } | |
| if (typeof apiErr?.status === "number") { | |
| throw new Error( | |
| `Codacy API returned an error (status ${apiErr.status}). Please try again or check your permissions.`, | |
| ); | |
| } |
| 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, | ||
| ); |
There was a problem hiding this comment.
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.
| try { | ||
| if (!fs.existsSync(CREDENTIALS_FILE)) { | ||
| return null; | ||
| } | ||
| const payload = fs.readFileSync(CREDENTIALS_FILE, "utf8"); | ||
| return decryptToken(payload); | ||
| } catch { |
There was a problem hiding this comment.
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”.
| 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" |
Adds login and logout commands. Token is stored encrypted in the user's directory.