Skip to content

Commit 2597eea

Browse files
committed
feat: add login and logout command
1 parent fc889c7 commit 2597eea

File tree

11 files changed

+649
-10
lines changed

11 files changed

+649
-10
lines changed

README.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,21 @@ npm link
2222

2323
## Authentication
2424

25-
Set your Codacy API token as an environment variable:
25+
Log in interactively (recommended):
26+
27+
```bash
28+
codacy login
29+
```
30+
31+
Or set the `CODACY_API_TOKEN` environment variable:
2632

2733
```bash
2834
export CODACY_API_TOKEN=your-token-here
2935
```
3036

31-
You can get a token from **Codacy > My Account > Access Management > Account API Tokens**.
37+
You can get a token from **Codacy > My Account > Access Management > API Tokens** ([link](https://app.codacy.com/account/access-management)).
38+
39+
The `login` command stores the token encrypted at `~/.codacy/credentials`. The environment variable takes precedence over stored credentials when both are present.
3240

3341
## Usage
3442

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

5058
| Command | Description |
5159
|---|---|
60+
| `login` | Authenticate with Codacy by storing your API token |
61+
| `logout` | Remove stored Codacy API token |
5262
| `info` | Show authenticated user info and their organizations |
5363
| `repositories <provider> <org>` | List repositories for an organization |
5464
| `repository <provider> <org> <repo>` | Show metrics for a repository, or add/remove/follow/unfollow/reanalyze it |

SPECS/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ _No pending tasks._ All commands implemented.
2626
| `pattern` | `pat` | ✅ Done | [tools-and-patterns.md](commands/tools-and-patterns.md) |
2727
| `analysis` | N/A | ✅ Done | [analysis.md](commands/analysis.md) |
2828
| `json-output` | N/A | ✅ Done | [json-output.md](commands/json-output.md) |
29+
| `login` | N/A | ✅ Done ||
30+
| `logout` | N/A | ✅ Done ||
2931

3032

3133
## Other Specs
@@ -62,3 +64,4 @@ _No pending tasks._ All commands implemented.
6264
| 2026-03-05 | Analysis status in `repository` and `pull-request` About sections using `formatAnalysisStatus()`; `--reanalyze` option for both commands (13 new tests, 185 total) |
6365
| 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` |
6466
| 2026-03-12 | `patterns --enable-all` / `--disable-all` bulk update with filter support (6 new tests, 196 total) |
67+
| 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) |

src/commands/login.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { Command } from "commander";
3+
import { registerLoginCommand } from "./login";
4+
import { AccountService } from "../api/client/services/AccountService";
5+
6+
vi.mock("../api/client/services/AccountService");
7+
vi.mock("../utils/credentials", () => ({
8+
saveCredentials: vi.fn(),
9+
getCredentialsPath: vi.fn(() => "/home/test/.codacy/credentials"),
10+
promptForToken: vi.fn(),
11+
}));
12+
13+
vi.spyOn(console, "log").mockImplementation(() => {});
14+
vi.spyOn(console, "error").mockImplementation(() => {});
15+
16+
import { saveCredentials, promptForToken } from "../utils/credentials";
17+
18+
function createProgram(): Command {
19+
const program = new Command();
20+
program.exitOverride();
21+
registerLoginCommand(program);
22+
return program;
23+
}
24+
25+
const mockUser = {
26+
name: "Test User",
27+
mainEmail: "test@example.com",
28+
otherEmails: [],
29+
isAdmin: false,
30+
isActive: true,
31+
};
32+
33+
describe("login command", () => {
34+
beforeEach(() => {
35+
vi.clearAllMocks();
36+
});
37+
38+
it("should validate and store token via --token flag", async () => {
39+
vi.mocked(AccountService.getUser).mockResolvedValue({ data: mockUser });
40+
41+
const program = createProgram();
42+
await program.parseAsync(["node", "test", "login", "--token", "my-token"]);
43+
44+
expect(AccountService.getUser).toHaveBeenCalledOnce();
45+
expect(saveCredentials).toHaveBeenCalledWith("my-token");
46+
expect(console.log).toHaveBeenCalledWith(
47+
expect.stringContaining("Token stored at"),
48+
);
49+
});
50+
51+
it("should show error on invalid token (401)", async () => {
52+
const apiError = new Error("Unauthorized");
53+
(apiError as any).status = 401;
54+
vi.mocked(AccountService.getUser).mockRejectedValue(apiError);
55+
56+
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
57+
throw new Error("process.exit called");
58+
});
59+
60+
const program = createProgram();
61+
await expect(
62+
program.parseAsync(["node", "test", "login", "--token", "bad-token"]),
63+
).rejects.toThrow("process.exit called");
64+
65+
expect(saveCredentials).not.toHaveBeenCalled();
66+
mockExit.mockRestore();
67+
});
68+
69+
it("should show network error when API is unreachable", async () => {
70+
vi.mocked(AccountService.getUser).mockRejectedValue(
71+
new Error("fetch failed"),
72+
);
73+
74+
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
75+
throw new Error("process.exit called");
76+
});
77+
78+
const program = createProgram();
79+
await expect(
80+
program.parseAsync(["node", "test", "login", "--token", "some-token"]),
81+
).rejects.toThrow("process.exit called");
82+
83+
expect(saveCredentials).not.toHaveBeenCalled();
84+
mockExit.mockRestore();
85+
});
86+
87+
it("should use interactive prompt when no --token flag", async () => {
88+
vi.mocked(promptForToken).mockResolvedValue("prompted-token");
89+
vi.mocked(AccountService.getUser).mockResolvedValue({ data: mockUser });
90+
91+
const program = createProgram();
92+
await program.parseAsync(["node", "test", "login"]);
93+
94+
expect(promptForToken).toHaveBeenCalledWith("API Token: ");
95+
expect(saveCredentials).toHaveBeenCalledWith("prompted-token");
96+
});
97+
98+
it("should reject empty token from interactive prompt", async () => {
99+
vi.mocked(promptForToken).mockResolvedValue(" ");
100+
101+
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
102+
throw new Error("process.exit called");
103+
});
104+
105+
const program = createProgram();
106+
await expect(
107+
program.parseAsync(["node", "test", "login"]),
108+
).rejects.toThrow("process.exit called");
109+
110+
expect(saveCredentials).not.toHaveBeenCalled();
111+
mockExit.mockRestore();
112+
});
113+
});

src/commands/login.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { Command } from "commander";
2+
import ansis from "ansis";
3+
import ora from "ora";
4+
import { OpenAPI } from "../api/client/core/OpenAPI";
5+
import { AccountService } from "../api/client/services/AccountService";
6+
import { handleError } from "../utils/error";
7+
import {
8+
saveCredentials,
9+
getCredentialsPath,
10+
promptForToken,
11+
} from "../utils/credentials";
12+
13+
export function registerLoginCommand(program: Command) {
14+
program
15+
.command("login")
16+
.description("Authenticate with Codacy by storing your API token")
17+
.option("-t, --token <token>", "API token (skips interactive prompt)")
18+
.addHelpText(
19+
"after",
20+
`
21+
Examples:
22+
$ codacy login
23+
$ codacy login --token <your-api-token>
24+
25+
Get your token at: https://app.codacy.com/account/access-management
26+
My Account > Access Management > API Tokens`,
27+
)
28+
.action(async (options) => {
29+
try {
30+
let token: string;
31+
32+
if (options.token) {
33+
token = options.token;
34+
} else {
35+
console.log(ansis.bold("\nCodacy Login\n"));
36+
console.log("You need an Account API Token to authenticate.");
37+
console.log(
38+
`Get one at: ${ansis.cyan("https://app.codacy.com/account/access-management")}`,
39+
);
40+
console.log(
41+
ansis.dim(" My Account > Access Management > API Tokens\n"),
42+
);
43+
44+
token = await promptForToken("API Token: ");
45+
46+
if (!token.trim()) {
47+
console.error(ansis.red("Error: Token cannot be empty."));
48+
process.exit(1);
49+
}
50+
51+
token = token.trim();
52+
}
53+
54+
const spinner = ora("Validating token...").start();
55+
56+
OpenAPI.HEADERS = {
57+
"api-token": token,
58+
"X-Codacy-Origin": "cli-cloud-tool",
59+
};
60+
61+
let userName: string;
62+
let userEmail: string;
63+
try {
64+
const response = await AccountService.getUser();
65+
userName = response.data.name || "Unknown";
66+
userEmail = response.data.mainEmail;
67+
} catch (apiErr: any) {
68+
spinner.fail("Authentication failed.");
69+
if (apiErr?.status === 401) {
70+
throw new Error(
71+
"Invalid API token. Check that it is correct and not expired.",
72+
);
73+
}
74+
throw new Error(
75+
"Could not reach the Codacy API. Check your network connection.",
76+
);
77+
}
78+
79+
saveCredentials(token);
80+
spinner.succeed(`Logged in as ${ansis.bold(userName)} (${userEmail})`);
81+
console.log(ansis.dim(` Token stored at ${getCredentialsPath()}`));
82+
} catch (err) {
83+
handleError(err);
84+
}
85+
});
86+
}

src/commands/logout.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { Command } from "commander";
3+
import { registerLogoutCommand } from "./logout";
4+
5+
vi.mock("../utils/credentials", () => ({
6+
deleteCredentials: vi.fn(),
7+
getCredentialsPath: vi.fn(() => "/home/test/.codacy/credentials"),
8+
}));
9+
10+
vi.spyOn(console, "log").mockImplementation(() => {});
11+
12+
import { deleteCredentials } from "../utils/credentials";
13+
14+
function createProgram(): Command {
15+
const program = new Command();
16+
registerLogoutCommand(program);
17+
return program;
18+
}
19+
20+
describe("logout command", () => {
21+
beforeEach(() => {
22+
vi.clearAllMocks();
23+
});
24+
25+
it("should delete credentials and show confirmation", async () => {
26+
vi.mocked(deleteCredentials).mockReturnValue(true);
27+
28+
const program = createProgram();
29+
await program.parseAsync(["node", "test", "logout"]);
30+
31+
expect(deleteCredentials).toHaveBeenCalledOnce();
32+
expect(console.log).toHaveBeenCalledWith(
33+
expect.stringContaining("Logged out"),
34+
);
35+
});
36+
37+
it("should show message when no credentials exist", async () => {
38+
vi.mocked(deleteCredentials).mockReturnValue(false);
39+
40+
const program = createProgram();
41+
await program.parseAsync(["node", "test", "logout"]);
42+
43+
expect(deleteCredentials).toHaveBeenCalledOnce();
44+
expect(console.log).toHaveBeenCalledWith("No stored credentials found.");
45+
});
46+
});

src/commands/logout.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { Command } from "commander";
2+
import ansis from "ansis";
3+
import {
4+
deleteCredentials,
5+
getCredentialsPath,
6+
} from "../utils/credentials";
7+
import { handleError } from "../utils/error";
8+
9+
export function registerLogoutCommand(program: Command) {
10+
program
11+
.command("logout")
12+
.description("Remove stored Codacy API token")
13+
.addHelpText(
14+
"after",
15+
`
16+
Examples:
17+
$ codacy logout`,
18+
)
19+
.action(() => {
20+
try {
21+
const deleted = deleteCredentials();
22+
if (deleted) {
23+
console.log(
24+
ansis.green("Logged out.") +
25+
ansis.dim(` Removed ${getCredentialsPath()}`),
26+
);
27+
} else {
28+
console.log("No stored credentials found.");
29+
}
30+
} catch (err) {
31+
handleError(err);
32+
}
33+
});
34+
}

src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { registerToolsCommand } from "./commands/tools";
1313
import { registerToolCommand } from "./commands/tool";
1414
import { registerPatternsCommand } from "./commands/patterns";
1515
import { registerPatternCommand } from "./commands/pattern";
16+
import { registerLoginCommand } from "./commands/login";
17+
import { registerLogoutCommand } from "./commands/logout";
1618

1719
const program = new Command();
1820

@@ -40,5 +42,7 @@ registerToolsCommand(program);
4042
registerToolCommand(program);
4143
registerPatternsCommand(program);
4244
registerPatternCommand(program);
45+
registerLoginCommand(program);
46+
registerLogoutCommand(program);
4347

4448
program.parse(process.argv);

src/utils/auth.test.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,38 @@
1-
import { describe, it, expect, beforeEach } from "vitest";
1+
import { describe, it, expect, beforeEach, vi } from "vitest";
22
import { checkApiToken } from "./auth";
33

4+
vi.mock("./credentials", () => ({
5+
loadCredentials: vi.fn(() => null),
6+
}));
7+
8+
import { loadCredentials } from "./credentials";
9+
410
describe("checkApiToken", () => {
511
beforeEach(() => {
612
delete process.env.CODACY_API_TOKEN;
13+
vi.mocked(loadCredentials).mockReturnValue(null);
714
});
815

9-
it("should return the token when set", () => {
16+
it("should return the token when CODACY_API_TOKEN is set", () => {
1017
process.env.CODACY_API_TOKEN = "my-token";
1118
expect(checkApiToken()).toBe("my-token");
1219
});
1320

14-
it("should throw when CODACY_API_TOKEN is not set", () => {
21+
it("should prefer env var over stored credentials", () => {
22+
process.env.CODACY_API_TOKEN = "env-token";
23+
vi.mocked(loadCredentials).mockReturnValue("stored-token");
24+
expect(checkApiToken()).toBe("env-token");
25+
expect(loadCredentials).not.toHaveBeenCalled();
26+
});
27+
28+
it("should fall back to stored credentials when env var is not set", () => {
29+
vi.mocked(loadCredentials).mockReturnValue("stored-token");
30+
expect(checkApiToken()).toBe("stored-token");
31+
});
32+
33+
it("should throw when no env var and no stored credentials", () => {
1534
expect(() => checkApiToken()).toThrow(
16-
"CODACY_API_TOKEN environment variable is not set"
35+
"No API token found. Set CODACY_API_TOKEN or run 'codacy login'.",
1736
);
1837
});
1938
});

0 commit comments

Comments
 (0)