Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,21 @@ npm link

## Authentication

Set your Codacy API token as an environment variable:
Log in interactively (recommended):

```bash
codacy login
```

Or set the `CODACY_API_TOKEN` environment variable:

```bash
export CODACY_API_TOKEN=your-token-here
```

You can get a token from **Codacy > My Account > Access Management > Account API Tokens**.
You can get a token from **Codacy > My Account > Access Management > API Tokens** ([link](https://app.codacy.com/account/access-management)).

The `login` command stores the token encrypted at `~/.codacy/credentials`. The environment variable takes precedence over stored credentials when both are present.
Comment thread
alerizzo marked this conversation as resolved.

## Usage

Expand All @@ -49,6 +57,8 @@ codacy <command> --help # Detailed usage for any command

| Command | Description |
|---|---|
| `login` | Authenticate with Codacy by storing your API token |
| `logout` | Remove stored Codacy API token |
| `info` | Show authenticated user info and their organizations |
| `repositories <provider> <org>` | List repositories for an organization |
| `repository <provider> <org> <repo>` | Show metrics for a repository, or add/remove/follow/unfollow/reanalyze it |
Expand Down
3 changes: 3 additions & 0 deletions SPECS/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ _No pending tasks._ All commands implemented.
| `pattern` | `pat` | ✅ Done | [tools-and-patterns.md](commands/tools-and-patterns.md) |
| `analysis` | N/A | ✅ Done | [analysis.md](commands/analysis.md) |
| `json-output` | N/A | ✅ Done | [json-output.md](commands/json-output.md) |
| `login` | N/A | ✅ Done | — |
| `logout` | N/A | ✅ Done | — |


## Other Specs
Expand Down Expand Up @@ -62,3 +64,4 @@ _No pending tasks._ All commands implemented.
| 2026-03-05 | Analysis status in `repository` and `pull-request` About sections using `formatAnalysisStatus()`; `--reanalyze` option for both commands (13 new tests, 185 total) |
| 2026-03-05 | JSON output filtering with `pickDeep` across all commands: `info`, `repositories`, `repository`, `pull-request`, `issues`, `issue`, `findings`, `finding`, `tools`, `patterns`; documented pattern in `src/commands/CLAUDE.md` |
| 2026-03-12 | `patterns --enable-all` / `--disable-all` bulk update with filter support (6 new tests, 196 total) |
| 2026-03-12 | `login` and `logout` commands: encrypted token storage in `~/.codacy/credentials`, masked interactive prompt, `--token` flag for non-interactive use, token resolution chain (env var → stored credentials); `checkApiToken()` updated to set `OpenAPI.HEADERS` dynamically (9 new tests, 219 total) |
Comment thread
alerizzo marked this conversation as resolved.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
"start:dist": "node dist/index.js",
"fetch-api": "curl https://artifacts.codacy.com/api/codacy-api/50.7.17/apiv3-bundled.yaml -o ./api-v3/api-swagger.yaml --create-dirs",
"generate-api": "rm -rf ./src/api/client && openapi --input ./api-v3/api-swagger.yaml --output ./src/api/client --useUnionTypes --indent 2 --client fetch",
"update-api": "npm run fetch-api && npm run generate-api"
"update-api": "npm run fetch-api && npm run generate-api",
"check-types": "tsc --noEmit"
},
"keywords": [
"codacy",
Expand Down
115 changes: 115 additions & 0 deletions src/commands/login.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Command } from "commander";
import { registerLoginCommand } from "./login";
import { AccountService } from "../api/client/services/AccountService";

vi.mock("../api/client/services/AccountService");
vi.mock("../utils/credentials", () => ({
saveCredentials: vi.fn(),
getCredentialsPath: vi.fn(() => "/home/test/.codacy/credentials"),
promptForToken: vi.fn(),
}));

vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});

import { saveCredentials, promptForToken } from "../utils/credentials";

function createProgram(): Command {
const program = new Command();
program.exitOverride();
registerLoginCommand(program);
return program;
}

const mockUser = {
id: 1,
name: "Test User",
mainEmail: "test@example.com",
otherEmails: [],
isAdmin: false,
isActive: true,
created: "2024-01-01",
};

describe("login command", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("should validate and store token via --token flag", async () => {
vi.mocked(AccountService.getUser).mockResolvedValue({ data: mockUser });

const program = createProgram();
await program.parseAsync(["node", "test", "login", "--token", "my-token"]);

expect(AccountService.getUser).toHaveBeenCalledOnce();
expect(saveCredentials).toHaveBeenCalledWith("my-token");
expect(console.log).toHaveBeenCalledWith(
expect.stringContaining("Token stored at"),
);
});

it("should show error on invalid token (401)", async () => {
const apiError = new Error("Unauthorized");
(apiError as any).status = 401;
vi.mocked(AccountService.getUser).mockRejectedValue(apiError);

const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit called");
});

const program = createProgram();
await expect(
program.parseAsync(["node", "test", "login", "--token", "bad-token"]),
).rejects.toThrow("process.exit called");

expect(saveCredentials).not.toHaveBeenCalled();
mockExit.mockRestore();
});

it("should show network error when API is unreachable", async () => {
vi.mocked(AccountService.getUser).mockRejectedValue(
new Error("fetch failed"),
);

const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit called");
});

const program = createProgram();
await expect(
program.parseAsync(["node", "test", "login", "--token", "some-token"]),
).rejects.toThrow("process.exit called");

expect(saveCredentials).not.toHaveBeenCalled();
mockExit.mockRestore();
});

it("should use interactive prompt when no --token flag", async () => {
vi.mocked(promptForToken).mockResolvedValue("prompted-token");
vi.mocked(AccountService.getUser).mockResolvedValue({ data: mockUser });

const program = createProgram();
await program.parseAsync(["node", "test", "login"]);

expect(promptForToken).toHaveBeenCalledWith("API Token: ");
expect(saveCredentials).toHaveBeenCalledWith("prompted-token");
});

it("should reject empty token from interactive prompt", async () => {
vi.mocked(promptForToken).mockResolvedValue(" ");

const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit called");
});

const program = createProgram();
await expect(
program.parseAsync(["node", "test", "login"]),
).rejects.toThrow("process.exit called");

expect(saveCredentials).not.toHaveBeenCalled();
mockExit.mockRestore();
});
});
96 changes: 96 additions & 0 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { Command } from "commander";
import ansis from "ansis";
import ora from "ora";
import { OpenAPI } from "../api/client/core/OpenAPI";
import { AccountService } from "../api/client/services/AccountService";
import { handleError } from "../utils/error";
import {
saveCredentials,
getCredentialsPath,
promptForToken,
} from "../utils/credentials";

export function registerLoginCommand(program: Command) {
program
.command("login")
.description("Authenticate with Codacy by storing your API token")
.option("-t, --token <token>", "API token (skips interactive prompt)")
.addHelpText(
"after",
`
Examples:
$ codacy login
$ codacy login --token <your-api-token>

Get your token at: https://app.codacy.com/account/access-management
My Account > Access Management > API Tokens`,
)
.action(async (options) => {
try {
let token: string;

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);
}
Comment on lines +32 to +52
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.

token = token.trim();
}

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.

let userName: string;
let userEmail: string;
try {
const response = await AccountService.getUser();
userName = response.data.name || "Unknown";
userEmail = response.data.mainEmail;
} catch (apiErr: any) {
spinner.fail("Authentication failed.");
if (apiErr?.status === 401) {
throw new Error(
"Invalid API token. Check that it is correct and not expired.",
);
}
Comment thread
alerizzo marked this conversation as resolved.
if (typeof apiErr?.status === "number") {
throw new Error(
`Codacy API returned an error (status ${apiErr.status}). Please try again or check your permissions.`,
);
}
throw new Error(
"Could not reach the Codacy API. Check your network connection.",
Comment thread
alerizzo marked this conversation as resolved.
);
}

saveCredentials(token);
spinner.succeed(`Logged in as ${ansis.bold(userName)} (${userEmail})`);
console.log(ansis.dim(` Token stored at ${getCredentialsPath()}`));
} catch (err) {
handleError(err);
}
});
}
46 changes: 46 additions & 0 deletions src/commands/logout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Command } from "commander";
import { registerLogoutCommand } from "./logout";

vi.mock("../utils/credentials", () => ({
deleteCredentials: vi.fn(),
getCredentialsPath: vi.fn(() => "/home/test/.codacy/credentials"),
}));

vi.spyOn(console, "log").mockImplementation(() => {});

import { deleteCredentials } from "../utils/credentials";

function createProgram(): Command {
const program = new Command();
registerLogoutCommand(program);
return program;
}

describe("logout command", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("should delete credentials and show confirmation", async () => {
vi.mocked(deleteCredentials).mockReturnValue(true);

const program = createProgram();
await program.parseAsync(["node", "test", "logout"]);

expect(deleteCredentials).toHaveBeenCalledOnce();
expect(console.log).toHaveBeenCalledWith(
expect.stringContaining("Logged out"),
);
});

it("should show message when no credentials exist", async () => {
vi.mocked(deleteCredentials).mockReturnValue(false);

const program = createProgram();
await program.parseAsync(["node", "test", "logout"]);

expect(deleteCredentials).toHaveBeenCalledOnce();
expect(console.log).toHaveBeenCalledWith("No stored credentials found.");
});
});
34 changes: 34 additions & 0 deletions src/commands/logout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Command } from "commander";
import ansis from "ansis";
import {
deleteCredentials,
getCredentialsPath,
} from "../utils/credentials";
import { handleError } from "../utils/error";

export function registerLogoutCommand(program: Command) {
program
.command("logout")
.description("Remove stored Codacy API token")
.addHelpText(
"after",
`
Examples:
$ codacy logout`,
)
.action(() => {
try {
const deleted = deleteCredentials();
if (deleted) {
console.log(
ansis.green("Logged out.") +
ansis.dim(` Removed ${getCredentialsPath()}`),
);
} else {
console.log("No stored credentials found.");
}
} catch (err) {
handleError(err);
}
});
}
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { registerToolsCommand } from "./commands/tools";
import { registerToolCommand } from "./commands/tool";
import { registerPatternsCommand } from "./commands/patterns";
import { registerPatternCommand } from "./commands/pattern";
import { registerLoginCommand } from "./commands/login";
import { registerLogoutCommand } from "./commands/logout";

const program = new Command();

Expand Down Expand Up @@ -40,5 +42,7 @@ registerToolsCommand(program);
registerToolCommand(program);
registerPatternsCommand(program);
registerPatternCommand(program);
registerLoginCommand(program);
registerLogoutCommand(program);

program.parse(process.argv);
Loading
Loading