From 2597eeab539ba36a489c5425fe5393f8d9ad88cb Mon Sep 17 00:00:00 2001 From: Alejandro Rizzo Date: Thu, 12 Mar 2026 17:51:32 +0000 Subject: [PATCH 1/6] feat: add login and logout command --- README.md | 14 ++- SPECS/README.md | 3 + src/commands/login.test.ts | 113 ++++++++++++++++++++++++ src/commands/login.ts | 86 ++++++++++++++++++ src/commands/logout.test.ts | 46 ++++++++++ src/commands/logout.ts | 34 +++++++ src/index.ts | 4 + src/utils/auth.test.ts | 27 +++++- src/utils/auth.ts | 28 +++++- src/utils/credentials.test.ts | 143 ++++++++++++++++++++++++++++++ src/utils/credentials.ts | 161 ++++++++++++++++++++++++++++++++++ 11 files changed, 649 insertions(+), 10 deletions(-) create mode 100644 src/commands/login.test.ts create mode 100644 src/commands/login.ts create mode 100644 src/commands/logout.test.ts create mode 100644 src/commands/logout.ts create mode 100644 src/utils/credentials.test.ts create mode 100644 src/utils/credentials.ts diff --git a/README.md b/README.md index cd9032b..c959823 100644 --- a/README.md +++ b/README.md @@ -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. ## Usage @@ -49,6 +57,8 @@ codacy --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 ` | List repositories for an organization | | `repository ` | Show metrics for a repository, or add/remove/follow/unfollow/reanalyze it | diff --git a/SPECS/README.md b/SPECS/README.md index b5a8eb6..f2dec8c 100644 --- a/SPECS/README.md +++ b/SPECS/README.md @@ -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 @@ -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) | diff --git a/src/commands/login.test.ts b/src/commands/login.test.ts new file mode 100644 index 0000000..b88f926 --- /dev/null +++ b/src/commands/login.test.ts @@ -0,0 +1,113 @@ +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 = { + name: "Test User", + mainEmail: "test@example.com", + otherEmails: [], + isAdmin: false, + isActive: true, +}; + +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(); + }); +}); diff --git a/src/commands/login.ts b/src/commands/login.ts new file mode 100644 index 0000000..38e745f --- /dev/null +++ b/src/commands/login.ts @@ -0,0 +1,86 @@ +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 ", "API token (skips interactive prompt)") + .addHelpText( + "after", + ` +Examples: + $ codacy login + $ codacy login --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 = options.token; + } 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); + } + + token = token.trim(); + } + + const spinner = ora("Validating token...").start(); + + OpenAPI.HEADERS = { + "api-token": token, + "X-Codacy-Origin": "cli-cloud-tool", + }; + + 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.", + ); + } + throw new Error( + "Could not reach the Codacy API. Check your network connection.", + ); + } + + saveCredentials(token); + spinner.succeed(`Logged in as ${ansis.bold(userName)} (${userEmail})`); + console.log(ansis.dim(` Token stored at ${getCredentialsPath()}`)); + } catch (err) { + handleError(err); + } + }); +} diff --git a/src/commands/logout.test.ts b/src/commands/logout.test.ts new file mode 100644 index 0000000..b3c9c15 --- /dev/null +++ b/src/commands/logout.test.ts @@ -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."); + }); +}); diff --git a/src/commands/logout.ts b/src/commands/logout.ts new file mode 100644 index 0000000..1260f0c --- /dev/null +++ b/src/commands/logout.ts @@ -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); + } + }); +} diff --git a/src/index.ts b/src/index.ts index 05a933b..36b65c6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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(); @@ -40,5 +42,7 @@ registerToolsCommand(program); registerToolCommand(program); registerPatternsCommand(program); registerPatternCommand(program); +registerLoginCommand(program); +registerLogoutCommand(program); program.parse(process.argv); diff --git a/src/utils/auth.test.ts b/src/utils/auth.test.ts index 7289bcd..2077803 100644 --- a/src/utils/auth.test.ts +++ b/src/utils/auth.test.ts @@ -1,19 +1,38 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, vi } from "vitest"; import { checkApiToken } from "./auth"; +vi.mock("./credentials", () => ({ + loadCredentials: vi.fn(() => null), +})); + +import { loadCredentials } from "./credentials"; + describe("checkApiToken", () => { beforeEach(() => { delete process.env.CODACY_API_TOKEN; + vi.mocked(loadCredentials).mockReturnValue(null); }); - it("should return the token when set", () => { + it("should return the token when CODACY_API_TOKEN is set", () => { process.env.CODACY_API_TOKEN = "my-token"; expect(checkApiToken()).toBe("my-token"); }); - it("should throw when CODACY_API_TOKEN is not set", () => { + it("should prefer env var over stored credentials", () => { + process.env.CODACY_API_TOKEN = "env-token"; + vi.mocked(loadCredentials).mockReturnValue("stored-token"); + expect(checkApiToken()).toBe("env-token"); + expect(loadCredentials).not.toHaveBeenCalled(); + }); + + it("should fall back to stored credentials when env var is not set", () => { + vi.mocked(loadCredentials).mockReturnValue("stored-token"); + expect(checkApiToken()).toBe("stored-token"); + }); + + it("should throw when no env var and no stored credentials", () => { expect(() => checkApiToken()).toThrow( - "CODACY_API_TOKEN environment variable is not set" + "No API token found. Set CODACY_API_TOKEN or run 'codacy login'.", ); }); }); diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 5bba34a..8900a30 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -1,7 +1,27 @@ +import { OpenAPI } from "../api/client/core/OpenAPI"; +import { loadCredentials } from "./credentials"; + +function updateApiHeaders(token: string): void { + OpenAPI.HEADERS = { + "api-token": token, + "X-Codacy-Origin": "cli-cloud-tool", + }; +} + export function checkApiToken(): string { - const apiToken = process.env.CODACY_API_TOKEN; - if (!apiToken) { - throw new Error("CODACY_API_TOKEN environment variable is not set."); + const envToken = process.env.CODACY_API_TOKEN; + if (envToken) { + updateApiHeaders(envToken); + return envToken; + } + + const stored = loadCredentials(); + if (stored) { + updateApiHeaders(stored); + return stored; } - return apiToken; + + throw new Error( + "No API token found. Set CODACY_API_TOKEN or run 'codacy login'.", + ); } diff --git a/src/utils/credentials.test.ts b/src/utils/credentials.test.ts new file mode 100644 index 0000000..a5c125e --- /dev/null +++ b/src/utils/credentials.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { + encryptToken, + decryptToken, + saveCredentials, + loadCredentials, + deleteCredentials, + getCredentialsPath, +} from "./credentials"; + +const TEST_DIR = path.join(os.tmpdir(), ".codacy-test-" + process.pid); +const TEST_FILE = path.join(TEST_DIR, "credentials"); + +vi.mock("node:os", async () => { + const actual = await vi.importActual("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 credentialsFile = path.join(credentialsDir, "credentials"); + + beforeEach(() => { + fs.rmSync(credentialsDir, { recursive: true, force: true }); + }); + + afterEach(() => { + fs.rmSync(credentialsDir, { recursive: true, force: true }); + }); + + describe("encryptToken / decryptToken", () => { + it("should round-trip a token", () => { + const token = "codacy_abcdef123456"; + const encrypted = encryptToken(token); + const decrypted = decryptToken(encrypted); + expect(decrypted).toBe(token); + }); + + it("should produce valid JSON with expected fields", () => { + const encrypted = encryptToken("test-token"); + const parsed = JSON.parse(encrypted); + expect(parsed).toHaveProperty("salt"); + expect(parsed).toHaveProperty("iv"); + expect(parsed).toHaveProperty("authTag"); + expect(parsed).toHaveProperty("encrypted"); + }); + + it("should produce different ciphertexts for the same token (random salt/iv)", () => { + const token = "same-token"; + const a = encryptToken(token); + const b = encryptToken(token); + expect(a).not.toBe(b); + + expect(decryptToken(a)).toBe(token); + expect(decryptToken(b)).toBe(token); + }); + + it("should throw on tampered ciphertext", () => { + const encrypted = encryptToken("my-secret"); + const parsed = JSON.parse(encrypted); + parsed.encrypted = "deadbeef"; + expect(() => decryptToken(JSON.stringify(parsed))).toThrow(); + }); + + it("should throw on invalid JSON", () => { + expect(() => decryptToken("not-json")).toThrow(); + }); + }); + + describe("saveCredentials / loadCredentials", () => { + it("should save and load a token", () => { + saveCredentials("my-api-token"); + const loaded = loadCredentials(); + expect(loaded).toBe("my-api-token"); + }); + + it("should create the directory if it does not exist", () => { + expect(fs.existsSync(credentialsDir)).toBe(false); + saveCredentials("token"); + expect(fs.existsSync(credentialsDir)).toBe(true); + }); + + it("should overwrite existing credentials", () => { + saveCredentials("old-token"); + saveCredentials("new-token"); + expect(loadCredentials()).toBe("new-token"); + }); + }); + + describe("loadCredentials", () => { + it("should return null when file does not exist", () => { + expect(loadCredentials()).toBeNull(); + }); + + it("should return null when file is corrupt", () => { + fs.mkdirSync(credentialsDir, { recursive: true }); + fs.writeFileSync( + credentialsFile, + "this is not valid encrypted data", + "utf8", + ); + expect(loadCredentials()).toBeNull(); + }); + + it("should return null when JSON is valid but fields are wrong", () => { + fs.mkdirSync(credentialsDir, { recursive: true }); + fs.writeFileSync( + credentialsFile, + JSON.stringify({ salt: "aa", iv: "bb", authTag: "cc", encrypted: "dd" }), + "utf8", + ); + expect(loadCredentials()).toBeNull(); + }); + }); + + describe("deleteCredentials", () => { + it("should delete existing credentials and return true", () => { + saveCredentials("to-delete"); + expect(deleteCredentials()).toBe(true); + expect(fs.existsSync(credentialsFile)).toBe(false); + }); + + it("should return false when no credentials exist", () => { + expect(deleteCredentials()).toBe(false); + }); + }); + + describe("getCredentialsPath", () => { + it("should return a path ending with .codacy/credentials", () => { + const p = getCredentialsPath(); + expect(p).toMatch(/\.codacy[/\\]credentials$/); + }); + }); +}); diff --git a/src/utils/credentials.ts b/src/utils/credentials.ts new file mode 100644 index 0000000..a155672 --- /dev/null +++ b/src/utils/credentials.ts @@ -0,0 +1,161 @@ +import * as crypto from "node:crypto"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +const CREDENTIALS_DIR = path.join(os.homedir(), ".codacy"); +const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, "credentials"); + +const ALGORITHM = "aes-256-gcm"; +const KEY_LENGTH = 32; +const IV_LENGTH = 16; +const SALT_LENGTH = 32; +const PBKDF2_ITERATIONS = 100_000; +const PBKDF2_DIGEST = "sha512"; + +interface EncryptedPayload { + salt: string; + iv: string; + authTag: string; + encrypted: string; +} + +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, + ); +} + +export function encryptToken(token: string): string { + const salt = crypto.randomBytes(SALT_LENGTH); + const iv = crypto.randomBytes(IV_LENGTH); + const key = deriveKey(getMachineKey(), salt); + + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + const encrypted = Buffer.concat([ + cipher.update(token, "utf8"), + cipher.final(), + ]); + const authTag = cipher.getAuthTag(); + + const payload: EncryptedPayload = { + salt: salt.toString("hex"), + iv: iv.toString("hex"), + authTag: authTag.toString("hex"), + encrypted: encrypted.toString("hex"), + }; + + return JSON.stringify(payload); +} + +export function decryptToken(payloadJson: string): string { + const payload: EncryptedPayload = JSON.parse(payloadJson); + + const salt = Buffer.from(payload.salt, "hex"); + const iv = Buffer.from(payload.iv, "hex"); + const authTag = Buffer.from(payload.authTag, "hex"); + const encrypted = Buffer.from(payload.encrypted, "hex"); + const key = deriveKey(getMachineKey(), salt); + + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + + const decrypted = Buffer.concat([ + decipher.update(encrypted), + decipher.final(), + ]); + + return decrypted.toString("utf8"); +} + +export function getCredentialsPath(): string { + return CREDENTIALS_FILE; +} + +export function saveCredentials(token: string): void { + if (!fs.existsSync(CREDENTIALS_DIR)) { + fs.mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 }); + } + + const payload = encryptToken(token); + fs.writeFileSync(CREDENTIALS_FILE, payload, { encoding: "utf8", mode: 0o600 }); +} + +export function loadCredentials(): string | null { + try { + if (!fs.existsSync(CREDENTIALS_FILE)) { + return null; + } + const payload = fs.readFileSync(CREDENTIALS_FILE, "utf8"); + return decryptToken(payload); + } catch { + return null; + } +} + +export function deleteCredentials(): boolean { + try { + if (!fs.existsSync(CREDENTIALS_FILE)) { + return false; + } + fs.unlinkSync(CREDENTIALS_FILE); + return true; + } catch { + return false; + } +} + +export function promptForToken(prompt: string): Promise { + return new Promise((resolve, reject) => { + if (!process.stdin.isTTY) { + reject(new Error("Interactive input required. Use --token instead.")); + return; + } + + process.stdout.write(prompt); + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.setEncoding("utf8"); + + let token = ""; + + const onData = (char: string) => { + 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 cleanup = () => { + process.stdin.setRawMode(false); + process.stdin.pause(); + process.stdin.removeListener("data", onData); + }; + + process.stdin.on("data", onData); + }); +} From 0771e7ff3bc5cb7f6b7ef95125c2d93aeac452f2 Mon Sep 17 00:00:00 2001 From: Alejandro Rizzo Date: Thu, 12 Mar 2026 17:55:25 +0000 Subject: [PATCH 2/6] fix type errors --- package.json | 3 ++- src/commands/login.test.ts | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 1862eb3..cb6b586 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/commands/login.test.ts b/src/commands/login.test.ts index b88f926..9f23c6c 100644 --- a/src/commands/login.test.ts +++ b/src/commands/login.test.ts @@ -23,11 +23,13 @@ function createProgram(): Command { } const mockUser = { + id: 1, name: "Test User", mainEmail: "test@example.com", otherEmails: [], isAdmin: false, isActive: true, + created: "2024-01-01", }; describe("login command", () => { From 5d4863f8f54b0f7ed520703c1ceef6728db5698d Mon Sep 17 00:00:00 2001 From: Alejandro Rizzo Date: Thu, 12 Mar 2026 18:05:30 +0000 Subject: [PATCH 3/6] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/commands/login.ts | 7 ++++++- src/utils/credentials.test.ts | 3 --- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/commands/login.ts b/src/commands/login.ts index 38e745f..830cd5c 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -30,7 +30,12 @@ Get your token at: https://app.codacy.com/account/access-management let token: string; if (options.token) { - token = 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."); diff --git a/src/utils/credentials.test.ts b/src/utils/credentials.test.ts index a5c125e..4d923db 100644 --- a/src/utils/credentials.test.ts +++ b/src/utils/credentials.test.ts @@ -11,9 +11,6 @@ import { getCredentialsPath, } from "./credentials"; -const TEST_DIR = path.join(os.tmpdir(), ".codacy-test-" + process.pid); -const TEST_FILE = path.join(TEST_DIR, "credentials"); - vi.mock("node:os", async () => { const actual = await vi.importActual("node:os"); return { From ad8003cc6bb2c588bd12646edac4d436fa0c67af Mon Sep 17 00:00:00 2001 From: Alejandro Rizzo Date: Fri, 13 Mar 2026 09:30:29 +0000 Subject: [PATCH 4/6] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/commands/login.ts | 5 +++ src/utils/credentials.test.ts | 9 +++-- src/utils/credentials.ts | 65 +++++++++++++++++++++-------------- 3 files changed, 48 insertions(+), 31 deletions(-) diff --git a/src/commands/login.ts b/src/commands/login.ts index 830cd5c..e1e8288 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -76,6 +76,11 @@ Get your token at: https://app.codacy.com/account/access-management "Invalid API token. Check that it is correct and not expired.", ); } + 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.", ); diff --git a/src/utils/credentials.test.ts b/src/utils/credentials.test.ts index 4d923db..60e56e8 100644 --- a/src/utils/credentials.test.ts +++ b/src/utils/credentials.test.ts @@ -11,19 +11,18 @@ import { getCredentialsPath, } from "./credentials"; +const mockHomeDir = path.join(os.tmpdir(), `.codacy-home-${process.pid}`); + vi.mock("node:os", async () => { const actual = await vi.importActual("node:os"); return { ...actual, - homedir: () => os.tmpdir().replace(/\/$/, "") + "/.codacy-home-" + process.pid, + homedir: () => mockHomeDir, }; }); describe("credentials", () => { - const credentialsDir = path.join( - os.tmpdir().replace(/\/$/, "") + "/.codacy-home-" + process.pid, - ".codacy", - ); + const credentialsDir = path.join(mockHomeDir, ".codacy"); const credentialsFile = path.join(credentialsDir, "credentials"); beforeEach(() => { diff --git a/src/utils/credentials.ts b/src/utils/credentials.ts index a155672..14aae0b 100644 --- a/src/utils/credentials.ts +++ b/src/utils/credentials.ts @@ -91,26 +91,35 @@ export function saveCredentials(token: string): void { } export function loadCredentials(): string | null { + if (!fs.existsSync(CREDENTIALS_FILE)) { + return null; + } + + const payload = fs.readFileSync(CREDENTIALS_FILE, "utf8"); + try { - if (!fs.existsSync(CREDENTIALS_FILE)) { - return null; - } - const payload = fs.readFileSync(CREDENTIALS_FILE, "utf8"); return decryptToken(payload); } catch { + // Treat invalid/corrupted credentials as "no credentials" return null; } } export function deleteCredentials(): boolean { + if (!fs.existsSync(CREDENTIALS_FILE)) { + return false; + } + try { - if (!fs.existsSync(CREDENTIALS_FILE)) { - return false; - } fs.unlinkSync(CREDENTIALS_FILE); return true; - } catch { - return false; + } 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; } } @@ -129,24 +138,28 @@ export function promptForToken(prompt: string): Promise { let token = ""; const onData = (char: string) => { - 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"); + 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("*"); } - } else if (c >= " ") { - token += c; - process.stdout.write("*"); } }; From 8e586bec80ef24715cffe5128145abd192d5ed73 Mon Sep 17 00:00:00 2001 From: Alejandro Rizzo Date: Fri, 13 Mar 2026 10:15:34 +0000 Subject: [PATCH 5/6] fix pr comments --- src/commands/finding.test.ts | 1 + src/commands/findings.test.ts | 1 + src/commands/info.test.ts | 2 +- src/commands/issue.test.ts | 1 + src/commands/issues.test.ts | 1 + src/commands/login.ts | 13 ++++--------- src/commands/pattern.test.ts | 1 + src/commands/patterns.test.ts | 1 + src/commands/pull-request.test.ts | 1 + src/commands/repositories.test.ts | 1 + src/commands/repository.test.ts | 1 + src/commands/tool.test.ts | 1 + src/commands/tools.test.ts | 1 + src/utils/auth.ts | 2 +- src/utils/credentials.test.ts | 15 ++++++++++++--- 15 files changed, 29 insertions(+), 14 deletions(-) diff --git a/src/commands/finding.test.ts b/src/commands/finding.test.ts index 7c381b1..143ae0e 100644 --- a/src/commands/finding.test.ts +++ b/src/commands/finding.test.ts @@ -10,6 +10,7 @@ vi.mock("../api/client/services/SecurityService"); vi.mock("../api/client/services/AnalysisService"); vi.mock("../api/client/services/ToolsService"); vi.mock("../api/client/services/FileService"); +vi.mock("../utils/credentials", () => ({ loadCredentials: vi.fn(() => null) })); vi.spyOn(console, "log").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/src/commands/findings.test.ts b/src/commands/findings.test.ts index 9e2b648..d27afb9 100644 --- a/src/commands/findings.test.ts +++ b/src/commands/findings.test.ts @@ -4,6 +4,7 @@ import { registerFindingsCommand } from "./findings"; import { SecurityService } from "../api/client/services/SecurityService"; vi.mock("../api/client/services/SecurityService"); +vi.mock("../utils/credentials", () => ({ loadCredentials: vi.fn(() => null) })); vi.spyOn(console, "log").mockImplementation(() => {}); function createProgram(): Command { diff --git a/src/commands/info.test.ts b/src/commands/info.test.ts index 5943065..4a7aff7 100644 --- a/src/commands/info.test.ts +++ b/src/commands/info.test.ts @@ -4,7 +4,7 @@ import { registerInfoCommand } from "./info"; import { AccountService } from "../api/client/services/AccountService"; vi.mock("../api/client/services/AccountService"); -// Suppress console output during tests +vi.mock("../utils/credentials", () => ({ loadCredentials: vi.fn(() => null) })); vi.spyOn(console, "log").mockImplementation(() => {}); function createProgram(): Command { diff --git a/src/commands/issue.test.ts b/src/commands/issue.test.ts index 92c195d..ad93f50 100644 --- a/src/commands/issue.test.ts +++ b/src/commands/issue.test.ts @@ -9,6 +9,7 @@ import { IssueStateBody } from "../api/client/models/IssueStateBody"; vi.mock("../api/client/services/AnalysisService"); vi.mock("../api/client/services/ToolsService"); vi.mock("../api/client/services/FileService"); +vi.mock("../utils/credentials", () => ({ loadCredentials: vi.fn(() => null) })); vi.spyOn(console, "log").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/src/commands/issues.test.ts b/src/commands/issues.test.ts index 33744ca..fd504a4 100644 --- a/src/commands/issues.test.ts +++ b/src/commands/issues.test.ts @@ -4,6 +4,7 @@ import { registerIssuesCommand } from "./issues"; import { AnalysisService } from "../api/client/services/AnalysisService"; vi.mock("../api/client/services/AnalysisService"); +vi.mock("../utils/credentials", () => ({ loadCredentials: vi.fn(() => null) })); vi.spyOn(console, "log").mockImplementation(() => {}); function createProgram(): Command { diff --git a/src/commands/login.ts b/src/commands/login.ts index e1e8288..ad8584b 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -1,9 +1,9 @@ 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 { updateApiHeaders } from "../utils/auth"; import { saveCredentials, getCredentialsPath, @@ -33,8 +33,7 @@ Get your token at: https://app.codacy.com/account/access-management token = String(options.token).trim(); if (!token) { - console.error(ansis.red("Error: Token cannot be empty.")); - process.exit(1); + throw new Error("Token cannot be empty."); } } else { console.log(ansis.bold("\nCodacy Login\n")); @@ -49,8 +48,7 @@ Get your token at: https://app.codacy.com/account/access-management token = await promptForToken("API Token: "); if (!token.trim()) { - console.error(ansis.red("Error: Token cannot be empty.")); - process.exit(1); + throw new Error("Token cannot be empty."); } token = token.trim(); @@ -58,10 +56,7 @@ Get your token at: https://app.codacy.com/account/access-management const spinner = ora("Validating token...").start(); - OpenAPI.HEADERS = { - "api-token": token, - "X-Codacy-Origin": "cli-cloud-tool", - }; + updateApiHeaders(token); let userName: string; let userEmail: string; diff --git a/src/commands/pattern.test.ts b/src/commands/pattern.test.ts index 208836b..0059432 100644 --- a/src/commands/pattern.test.ts +++ b/src/commands/pattern.test.ts @@ -4,6 +4,7 @@ import { registerPatternCommand } from "./pattern"; import { AnalysisService } from "../api/client/services/AnalysisService"; vi.mock("../api/client/services/AnalysisService"); +vi.mock("../utils/credentials", () => ({ loadCredentials: vi.fn(() => null) })); vi.spyOn(console, "log").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/src/commands/patterns.test.ts b/src/commands/patterns.test.ts index 3059c84..ba829d9 100644 --- a/src/commands/patterns.test.ts +++ b/src/commands/patterns.test.ts @@ -4,6 +4,7 @@ import { registerPatternsCommand } from "./patterns"; import { AnalysisService } from "../api/client/services/AnalysisService"; vi.mock("../api/client/services/AnalysisService"); +vi.mock("../utils/credentials", () => ({ loadCredentials: vi.fn(() => null) })); vi.spyOn(console, "log").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/src/commands/pull-request.test.ts b/src/commands/pull-request.test.ts index 8c832ba..38e23ec 100644 --- a/src/commands/pull-request.test.ts +++ b/src/commands/pull-request.test.ts @@ -12,6 +12,7 @@ vi.mock("../api/client/services/CoverageService"); vi.mock("../api/client/services/RepositoryService"); vi.mock("../api/client/services/ToolsService"); vi.mock("../api/client/services/FileService"); +vi.mock("../utils/credentials", () => ({ loadCredentials: vi.fn(() => null) })); vi.spyOn(console, "log").mockImplementation(() => {}); function createProgram(): Command { diff --git a/src/commands/repositories.test.ts b/src/commands/repositories.test.ts index a6d8066..767566d 100644 --- a/src/commands/repositories.test.ts +++ b/src/commands/repositories.test.ts @@ -4,6 +4,7 @@ import { registerRepositoriesCommand } from "./repositories"; import { AnalysisService } from "../api/client/services/AnalysisService"; vi.mock("../api/client/services/AnalysisService"); +vi.mock("../utils/credentials", () => ({ loadCredentials: vi.fn(() => null) })); vi.spyOn(console, "log").mockImplementation(() => {}); function createProgram(): Command { diff --git a/src/commands/repository.test.ts b/src/commands/repository.test.ts index 52a76e3..0af6497 100644 --- a/src/commands/repository.test.ts +++ b/src/commands/repository.test.ts @@ -6,6 +6,7 @@ import { RepositoryService } from "../api/client/services/RepositoryService"; vi.mock("../api/client/services/AnalysisService"); vi.mock("../api/client/services/RepositoryService"); +vi.mock("../utils/credentials", () => ({ loadCredentials: vi.fn(() => null) })); vi.spyOn(console, "log").mockImplementation(() => {}); // Default mocks for analysis status API calls (overridden in specific tests) diff --git a/src/commands/tool.test.ts b/src/commands/tool.test.ts index c27995d..40ca390 100644 --- a/src/commands/tool.test.ts +++ b/src/commands/tool.test.ts @@ -4,6 +4,7 @@ import { registerToolCommand } from "./tool"; import { AnalysisService } from "../api/client/services/AnalysisService"; vi.mock("../api/client/services/AnalysisService"); +vi.mock("../utils/credentials", () => ({ loadCredentials: vi.fn(() => null) })); vi.spyOn(console, "log").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/src/commands/tools.test.ts b/src/commands/tools.test.ts index 854ed87..00e744d 100644 --- a/src/commands/tools.test.ts +++ b/src/commands/tools.test.ts @@ -4,6 +4,7 @@ import { registerToolsCommand } from "./tools"; import { AnalysisService } from "../api/client/services/AnalysisService"; vi.mock("../api/client/services/AnalysisService"); +vi.mock("../utils/credentials", () => ({ loadCredentials: vi.fn(() => null) })); vi.spyOn(console, "log").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 8900a30..e1b4f63 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -1,7 +1,7 @@ import { OpenAPI } from "../api/client/core/OpenAPI"; import { loadCredentials } from "./credentials"; -function updateApiHeaders(token: string): void { +export function updateApiHeaders(token: string): void { OpenAPI.HEADERS = { "api-token": token, "X-Codacy-Origin": "cli-cloud-tool", diff --git a/src/utils/credentials.test.ts b/src/utils/credentials.test.ts index 60e56e8..b14515e 100644 --- a/src/utils/credentials.test.ts +++ b/src/utils/credentials.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import * as fs from "node:fs"; import * as path from "node:path"; -import * as os from "node:os"; + import { encryptToken, decryptToken, @@ -11,7 +11,11 @@ import { getCredentialsPath, } from "./credentials"; -const mockHomeDir = path.join(os.tmpdir(), `.codacy-home-${process.pid}`); +const mockHomeDir = vi.hoisted(() => { + const os = require("node:os"); + const path = require("node:path"); + return path.join(os.tmpdir(), `.codacy-home-${process.pid}`); +}); vi.mock("node:os", async () => { const actual = await vi.importActual("node:os"); @@ -111,7 +115,12 @@ describe("credentials", () => { fs.mkdirSync(credentialsDir, { recursive: true }); fs.writeFileSync( credentialsFile, - JSON.stringify({ salt: "aa", iv: "bb", authTag: "cc", encrypted: "dd" }), + JSON.stringify({ + salt: "aa", + iv: "bb", + authTag: "cc", + encrypted: "dd", + }), "utf8", ); expect(loadCredentials()).toBeNull(); From 72aa7d1fc7b25254b9948805ca41a4f40f03cceb Mon Sep 17 00:00:00 2001 From: Alejandro Rizzo Date: Fri, 13 Mar 2026 10:18:34 +0000 Subject: [PATCH 6/6] chore: adopt AGENTS.md --- AGENTS.md | 186 ++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 187 +------------------------------------- src/commands/AGENTS.md | 200 ++++++++++++++++++++++++++++++++++++++++ src/commands/CLAUDE.md | 201 +---------------------------------------- 4 files changed, 388 insertions(+), 386 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/commands/AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6847655 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,186 @@ +# Codacy Cloud CLI + +A command-line Node.js + TypeScript tool to interact with the Codacy API. + +## Project Overview + +This CLI wraps the [Codacy Cloud API v3](https://api.codacy.com/api/api-docs) using an auto-generated TypeScript client. The goal is to provide a clean, well-structured CLI that lets users interact with Codacy directly from the terminal. + +**Current state:** The project has a working boilerplate with Commander.js, an auto-generated API client, and a few prototype commands (`user`, `orgs`, `repos`) that will be removed and rebuilt. The foundation (entry point, API generation pipeline, utilities) is stable and should be preserved. + +## Quick Reference + +| Action | Command | +|---|---| +| Run in dev mode | `npx ts-node src/index.ts ` | +| Build | `npm run build` | +| Run built version | `node dist/index.js ` | +| Fetch latest API spec | `npm run fetch-api` | +| Regenerate API client | `npm run generate-api` | +| Full API update | `npm run update-api` | +| Run tests | `npm test` | + +## Architecture & Project Structure + +``` +codacy-cloud-cli/ +├── src/ +│ ├── index.ts # CLI entry point (Commander.js setup) +│ ├── api/ +│ │ └── client/ # AUTO-GENERATED - do NOT edit manually +│ │ ├── core/ # Request handling, auth, errors +│ │ ├── models/ # 520+ TypeScript interfaces from OpenAPI +│ │ └── services/ # 28 service classes wrapping API endpoints +│ ├── commands/ # One file per command (see Command Pattern below) +│ │ └── CLAUDE.md # Design decisions for commands +│ └── utils/ # Shared utilities (auth, error handling, output formatting, formatting helpers) +├── api-v3/ +│ └── api-swagger.yaml # OpenAPI 3.0.1 spec (source of truth for client generation) +├── dist/ # Compiled JS output (gitignored) +├── SPECS/ # Specs and backlog - agents MUST read SPECS/README.md +│ ├── README.md # Agent landing page: pending tasks, command table, changelog +│ ├── commands/ # One spec file per command +│ ├── setup.md # Test framework, build, utilities +│ └── deployment.md # npm publishing, CI pipelines +├── TODO.md # Redirects to SPECS/README.md +├── CLAUDE.md # This file +├── package.json +└── tsconfig.json +``` + +## Critical Rules + +### For All Agents + +1. **Read `SPECS/README.md` before starting work.** It shows pending tasks, the command inventory, and the changelog. When completing a task, update the pending table and add a changelog entry. +2. **Never edit files under `src/api/client/`.** This directory is auto-generated. If the API client needs updating, run `npm run update-api`. +3. **Ask before assuming.** If a task in SPECS or a user instruction is ambiguous, ask clarifying questions before writing code. Do not guess intent. +4. **Document what you build.** Every command, utility, or significant piece of logic must include: + - Inline comments where the logic isn't self-evident + - A `CLAUDE.md` in the relevant folder explaining design and implementation decisions when the folder contains multiple related files +5. **Write tests for everything.** Every command must have corresponding tests. See Testing section below. +6. **One command per file.** Each CLI command lives in its own file inside `src/commands/`. The file exports a `registerCommand(program: Command)` function. +7. **Keep the entry point thin.** `src/index.ts` only handles Commander setup and command registration. No business logic belongs there. +8. **Keep `README.md` up to date, but concise.** The README contains only a short summary table of available commands and their one-line descriptions. Do **not** document per-command arguments, options, or examples in the README — users run `codacy --help` for that. After adding or renaming a command, add or update its row in the summary table only. + +### Code Style & Conventions + +- **Language:** TypeScript (strict mode) +- **Module system:** CommonJS (`"module": "commonjs"` in tsconfig) +- **CLI framework:** Commander.js v14 +- **Terminal output libraries:** + - `ansis` for colors/styling + - `cli-table3` for tabular output — always use `createTable()` from `utils/output.ts` (applies borderless styling and bold white headers) + - `ora` for loading spinners + - `dayjs` for date formatting — for "last updated" style dates, use `formatFriendlyDate()` from `utils/output.ts` (relative for today, "Yesterday", otherwise YYYY-MM-DD) +- **Output:** Default output is human readable with tables and colors, but can be overridden with the `--output json` flag. +- **Pagination:** All commands calling paginated APIs must call `printPaginationWarning(response.pagination, hint)` from `utils/output.ts` after displaying results. The hint should suggest command-specific filtering options. +- **Error handling:** Use `try/catch` with the shared `handleError()` from `src/utils/error.ts` +- **Authentication:** All commands that call the API must call `checkApiToken()` from `src/utils/auth.ts` before making requests +- **API base URL:** `https://app.codacy.com/api/v3` (configured in `src/index.ts` via `OpenAPI.BASE`) +- **Auth mechanism:** `CODACY_API_TOKEN` environment variable, sent as `api-token` header + +### Command Pattern + +Every command file follows this structure: + +```typescript +// src/commands/.ts +import { Command } from "commander"; +import ora from "ora"; +import { checkApiToken } from "../utils/auth"; +import { handleError } from "../utils/error"; +// Import relevant API service(s) + +export function registerCommand(program: Command) { + program + .command("") + .description("Clear description of what this command does") + .argument("[args]", "Description of arguments") + .option("--flag ", "Description of options") + .action(async (args, options) => { + try { + checkApiToken(); + const spinner = ora("Loading...").start(); + // Call API service + // Format and display output + spinner.succeed("Done."); + } catch (err) { + handleError(err); + } + }); +} +``` + +Then register it in `src/index.ts`: +```typescript +import { registerCommand } from "./commands/"; +registerNameCommand(program); +``` + +## API Client Generation + +The API client is auto-generated from the Codacy OpenAPI spec. **Never edit generated files.** + +- **Spec location:** `api-v3/api-swagger.yaml` +- **Generator:** `@codacy/openapi-typescript-codegen@0.0.8` +- **Output:** `src/api/client/` (models, services, core) +- **Client type:** fetch-based + +To update the API client: +```bash +npm run update-api # Fetches latest spec + regenerates client +npm run fetch-api # Only fetch the spec +npm run generate-api # Only regenerate from existing spec +``` + +When referencing API operations, look at the generated services in `src/api/client/services/` to find available methods and their signatures. The models in `src/api/client/models/` define the request/response types. + +## Testing + +### Setup + +Tests must be configured with a proper test framework (Vitest or Jest - check `package.json` for which is installed). Each command must have corresponding test files. + +### Test Strategy + +- **Unit tests** for utility functions and output formatting logic +- **Integration tests** for commands that call the Codacy API + - These tests will use a dedicated test organization and repository in Codacy with known, predictable data + - The test org/repo details will be configured via environment variables or test fixtures +- **Test file naming:** `.test.ts` co-located next to the source file, or in a `__tests__/` directory within the same folder +- **Mocking:** Mock API service calls for unit tests; use real API calls (with test credentials) for integration tests + +### Running Tests + +```bash +npm test +``` + +## Specs & Backlog + +The `SPECS/` folder at the project root is the single source of truth for specs and the project backlog. + +- **`SPECS/README.md`** — agent landing page: pending tasks table, command inventory, changelog +- **`SPECS/commands/.md`** — full spec for each command (API endpoints, output format, options, test counts) +- **`SPECS/setup.md`** — test framework, utilities reference +- **`SPECS/deployment.md`** — CI pipelines, npm publishing + +**Agents must:** +1. Read `SPECS/README.md` at the start of every session +2. Pick up the next pending task from the pending table (or the one specified by the user) +3. Read the relevant `SPECS/commands/.md` before implementing a command +4. Update `SPECS/README.md` (mark tasks done, add changelog entry) when completing work +5. Add new tasks to `SPECS/README.md` pending table when discovered during work + +## Environment Variables + +| Variable | Required | Description | +|---|---|---| +| `CODACY_API_TOKEN` | Yes | API token for authenticating with Codacy. Get it from Codacy > Account > API Tokens | + +## Useful Context + +- Codacy API docs: https://api.codacy.com/api/api-docs +- The CLI targets Codacy Cloud (app.codacy.com), not self-hosted instances +- Provider shortcodes used in commands: `gh` (GitHub), `gl` (GitLab), `bb` (Bitbucket) diff --git a/CLAUDE.md b/CLAUDE.md index 6847655..eef4bd2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,186 +1 @@ -# Codacy Cloud CLI - -A command-line Node.js + TypeScript tool to interact with the Codacy API. - -## Project Overview - -This CLI wraps the [Codacy Cloud API v3](https://api.codacy.com/api/api-docs) using an auto-generated TypeScript client. The goal is to provide a clean, well-structured CLI that lets users interact with Codacy directly from the terminal. - -**Current state:** The project has a working boilerplate with Commander.js, an auto-generated API client, and a few prototype commands (`user`, `orgs`, `repos`) that will be removed and rebuilt. The foundation (entry point, API generation pipeline, utilities) is stable and should be preserved. - -## Quick Reference - -| Action | Command | -|---|---| -| Run in dev mode | `npx ts-node src/index.ts ` | -| Build | `npm run build` | -| Run built version | `node dist/index.js ` | -| Fetch latest API spec | `npm run fetch-api` | -| Regenerate API client | `npm run generate-api` | -| Full API update | `npm run update-api` | -| Run tests | `npm test` | - -## Architecture & Project Structure - -``` -codacy-cloud-cli/ -├── src/ -│ ├── index.ts # CLI entry point (Commander.js setup) -│ ├── api/ -│ │ └── client/ # AUTO-GENERATED - do NOT edit manually -│ │ ├── core/ # Request handling, auth, errors -│ │ ├── models/ # 520+ TypeScript interfaces from OpenAPI -│ │ └── services/ # 28 service classes wrapping API endpoints -│ ├── commands/ # One file per command (see Command Pattern below) -│ │ └── CLAUDE.md # Design decisions for commands -│ └── utils/ # Shared utilities (auth, error handling, output formatting, formatting helpers) -├── api-v3/ -│ └── api-swagger.yaml # OpenAPI 3.0.1 spec (source of truth for client generation) -├── dist/ # Compiled JS output (gitignored) -├── SPECS/ # Specs and backlog - agents MUST read SPECS/README.md -│ ├── README.md # Agent landing page: pending tasks, command table, changelog -│ ├── commands/ # One spec file per command -│ ├── setup.md # Test framework, build, utilities -│ └── deployment.md # npm publishing, CI pipelines -├── TODO.md # Redirects to SPECS/README.md -├── CLAUDE.md # This file -├── package.json -└── tsconfig.json -``` - -## Critical Rules - -### For All Agents - -1. **Read `SPECS/README.md` before starting work.** It shows pending tasks, the command inventory, and the changelog. When completing a task, update the pending table and add a changelog entry. -2. **Never edit files under `src/api/client/`.** This directory is auto-generated. If the API client needs updating, run `npm run update-api`. -3. **Ask before assuming.** If a task in SPECS or a user instruction is ambiguous, ask clarifying questions before writing code. Do not guess intent. -4. **Document what you build.** Every command, utility, or significant piece of logic must include: - - Inline comments where the logic isn't self-evident - - A `CLAUDE.md` in the relevant folder explaining design and implementation decisions when the folder contains multiple related files -5. **Write tests for everything.** Every command must have corresponding tests. See Testing section below. -6. **One command per file.** Each CLI command lives in its own file inside `src/commands/`. The file exports a `registerCommand(program: Command)` function. -7. **Keep the entry point thin.** `src/index.ts` only handles Commander setup and command registration. No business logic belongs there. -8. **Keep `README.md` up to date, but concise.** The README contains only a short summary table of available commands and their one-line descriptions. Do **not** document per-command arguments, options, or examples in the README — users run `codacy --help` for that. After adding or renaming a command, add or update its row in the summary table only. - -### Code Style & Conventions - -- **Language:** TypeScript (strict mode) -- **Module system:** CommonJS (`"module": "commonjs"` in tsconfig) -- **CLI framework:** Commander.js v14 -- **Terminal output libraries:** - - `ansis` for colors/styling - - `cli-table3` for tabular output — always use `createTable()` from `utils/output.ts` (applies borderless styling and bold white headers) - - `ora` for loading spinners - - `dayjs` for date formatting — for "last updated" style dates, use `formatFriendlyDate()` from `utils/output.ts` (relative for today, "Yesterday", otherwise YYYY-MM-DD) -- **Output:** Default output is human readable with tables and colors, but can be overridden with the `--output json` flag. -- **Pagination:** All commands calling paginated APIs must call `printPaginationWarning(response.pagination, hint)` from `utils/output.ts` after displaying results. The hint should suggest command-specific filtering options. -- **Error handling:** Use `try/catch` with the shared `handleError()` from `src/utils/error.ts` -- **Authentication:** All commands that call the API must call `checkApiToken()` from `src/utils/auth.ts` before making requests -- **API base URL:** `https://app.codacy.com/api/v3` (configured in `src/index.ts` via `OpenAPI.BASE`) -- **Auth mechanism:** `CODACY_API_TOKEN` environment variable, sent as `api-token` header - -### Command Pattern - -Every command file follows this structure: - -```typescript -// src/commands/.ts -import { Command } from "commander"; -import ora from "ora"; -import { checkApiToken } from "../utils/auth"; -import { handleError } from "../utils/error"; -// Import relevant API service(s) - -export function registerCommand(program: Command) { - program - .command("") - .description("Clear description of what this command does") - .argument("[args]", "Description of arguments") - .option("--flag ", "Description of options") - .action(async (args, options) => { - try { - checkApiToken(); - const spinner = ora("Loading...").start(); - // Call API service - // Format and display output - spinner.succeed("Done."); - } catch (err) { - handleError(err); - } - }); -} -``` - -Then register it in `src/index.ts`: -```typescript -import { registerCommand } from "./commands/"; -registerNameCommand(program); -``` - -## API Client Generation - -The API client is auto-generated from the Codacy OpenAPI spec. **Never edit generated files.** - -- **Spec location:** `api-v3/api-swagger.yaml` -- **Generator:** `@codacy/openapi-typescript-codegen@0.0.8` -- **Output:** `src/api/client/` (models, services, core) -- **Client type:** fetch-based - -To update the API client: -```bash -npm run update-api # Fetches latest spec + regenerates client -npm run fetch-api # Only fetch the spec -npm run generate-api # Only regenerate from existing spec -``` - -When referencing API operations, look at the generated services in `src/api/client/services/` to find available methods and their signatures. The models in `src/api/client/models/` define the request/response types. - -## Testing - -### Setup - -Tests must be configured with a proper test framework (Vitest or Jest - check `package.json` for which is installed). Each command must have corresponding test files. - -### Test Strategy - -- **Unit tests** for utility functions and output formatting logic -- **Integration tests** for commands that call the Codacy API - - These tests will use a dedicated test organization and repository in Codacy with known, predictable data - - The test org/repo details will be configured via environment variables or test fixtures -- **Test file naming:** `.test.ts` co-located next to the source file, or in a `__tests__/` directory within the same folder -- **Mocking:** Mock API service calls for unit tests; use real API calls (with test credentials) for integration tests - -### Running Tests - -```bash -npm test -``` - -## Specs & Backlog - -The `SPECS/` folder at the project root is the single source of truth for specs and the project backlog. - -- **`SPECS/README.md`** — agent landing page: pending tasks table, command inventory, changelog -- **`SPECS/commands/.md`** — full spec for each command (API endpoints, output format, options, test counts) -- **`SPECS/setup.md`** — test framework, utilities reference -- **`SPECS/deployment.md`** — CI pipelines, npm publishing - -**Agents must:** -1. Read `SPECS/README.md` at the start of every session -2. Pick up the next pending task from the pending table (or the one specified by the user) -3. Read the relevant `SPECS/commands/.md` before implementing a command -4. Update `SPECS/README.md` (mark tasks done, add changelog entry) when completing work -5. Add new tasks to `SPECS/README.md` pending table when discovered during work - -## Environment Variables - -| Variable | Required | Description | -|---|---|---| -| `CODACY_API_TOKEN` | Yes | API token for authenticating with Codacy. Get it from Codacy > Account > API Tokens | - -## Useful Context - -- Codacy API docs: https://api.codacy.com/api/api-docs -- The CLI targets Codacy Cloud (app.codacy.com), not self-hosted instances -- Provider shortcodes used in commands: `gh` (GitHub), `gl` (GitLab), `bb` (Bitbucket) +@AGENTS.md \ No newline at end of file diff --git a/src/commands/AGENTS.md b/src/commands/AGENTS.md new file mode 100644 index 0000000..99293e9 --- /dev/null +++ b/src/commands/AGENTS.md @@ -0,0 +1,200 @@ +# Commands Design Decisions + +## Structure + +Each command is a single file that exports a `registerCommand(program: Command)` function. Commands are registered in `src/index.ts`. + +## Command Aliases + +Every command must declare a short alias via `.alias()`. Keep aliases short (2–4 characters) and intuitive: +- `repositories` → `repos` +- `repository` → `repo` +- `pull-request` → `pr` +- `issues` → `is` + +## Option Short Flags + +Every command option must have both a short flag and a long flag: `-X, --long-name `. Pick single letters that are intuitive and don't conflict with Commander's built-in flags (`-V/--version`, `-h/--help`) or the global `-o/--output` option. When the natural letter is already taken, use uppercase (e.g. `-O, --overview` instead of `-o`). + +## Option Naming: Singular vs Plural + +Use a **singular** option name when the parameter accepts a single value, and a **plural** name when it accepts a comma-separated list: + +- `--branch main` → singular (one branch) +- `--severities Critical,High` → plural (list of severity levels) +- `--categories Security,CodeStyle` → plural (list of categories) +- `--languages TypeScript,Python` → plural (list of languages) +- `--authors dev@example.com,other@example.com` → plural (list of emails) + +This applies to both the long flag name and the metavar: `--severities `, not `--severities `. + +## Output Format + +All commands support `--output json` via the global `-o, --output` option. Commands use `getOutputFormat(this)` (from `utils/output.ts`) to check the format and either: +- Render tables/styled output for `table` (default) +- Call `printJson(data)` for `json` + +The `this` context in Commander action handlers gives access to the command instance, which is used to read global options via `optsWithGlobals()`. + +## Table Styling + +Tables are created via `createTable()` from `utils/output.ts`, which applies default styling: +- No pipe borders — clean tabular format with space separators only +- Column headers are **bold white** (not the default red from cli-table3) + +## Pagination + +All commands that call paginated API endpoints must check for a cursor in the response and call `printPaginationWarning()` when more results exist. The warning includes a command-specific hint suggesting how to filter results: +- `info` (organizations): generic message ("Not all organizations are shown.") +- `repositories`: suggests `--search ` to filter by name + +New commands must follow this pattern: after printing the table, call `printPaginationWarning(response.pagination, "")`. + +## Date Formatting + +When displaying "Last Updated" or similar dates, use `formatFriendlyDate()` from `utils/output.ts`: +- Same day: relative time ("Just now", "10 minutes ago", "3 hours ago") +- Yesterday: "Yesterday" +- Older: "YYYY-MM-DD" + +## info command (`info.ts`) + +- Calls `AccountService.getUser()` and `AccountService.listUserOrganizations()` in parallel +- Displays user details in a key-value table (cli-table3 without headers) +- Displays organizations in a columnar table +- Provider codes (gh, gl, bb) are mapped to display names via `utils/providers.ts` +- Shows pagination warning if organizations response has more pages + +## Visibility Indicator + +Instead of a dedicated "Visibility" column (wastes horizontal space), public repositories are marked with a dimmed `⊙` (U+2299, circled dot operator) appended to the name. Private repositories show the name alone. This character is in the Mathematical Operators Unicode block and renders reliably across terminals. + +## repositories command (`repositories.ts`) + +- Takes `` and `` as required arguments +- Optional `--search ` passes through to the API's `search` parameter +- Public repos show `⊙` after the name instead of a separate Visibility column +- Quality metrics (complexity, duplication, coverage) are colored red/green based on `goals` thresholds from `RepositoryQualitySettings`: + - **Max thresholds** (issues, complexity, duplication): green if under, red if over + - **Min thresholds** (coverage): green if above, red if below +- Grade letters are colored: A/B = green, C = yellow, D/F = red +- Last Updated uses friendly date formatting (relative for today, "Yesterday", otherwise YYYY-MM-DD) +- Shows pagination warning if more results exist, suggesting `--search` to filter + +## repository command (`repository.ts`) + +- Takes ``, ``, and `` as required arguments +- Calls three API endpoints in parallel: `getRepositoryWithAnalysis`, `listRepositoryPullRequests`, `issuesOverview` +- Displays a multi-section dashboard: + - **About**: provider/org/name, visibility, default branch, last updated (friendly date), last analysis (time + short SHA) + - **Setup**: languages, coding standards, quality gate, problems (yellow if present, green "None" otherwise) + - **Metrics**: issues (count + per kLoC), coverage, complexity, duplication — colored by goals thresholds + - **Open Pull Requests**: filtered to open status, columns: + - `#`, `Title` (truncated at 50), `Branch` (truncated at 40) + - `✓` (header is a gray ✓) — green ✓ if `isUpToStandards` is true, red ✗ if false, dim `-` if undefined + - `Issues` — combined `+newIssues / -fixedIssues` + - `Coverage` — `diffCoverage% (+/-deltaCoverage%)` + - `Complexity` — delta value with +/- sign + - `Duplication` — delta clones count with +/- sign + - `Updated` — friendly date + - **Metric coloring via `resultReasons`**: The `quality.resultReasons` and `coverage.resultReasons` arrays contain per-gate pass/fail info (`gate` name + `isUpToStandards`). Gate names are matched by keyword to color the corresponding metric column: + - `"issue"` (not security) → Issues column + - `"coverage"` → Coverage column + - `"complexity"` → Complexity column + - `"duplication"` or `"clone"` → Duplication column + - Green if gate passes, red if gate fails; no coloring if no matching gate exists + - **Issues Overview**: three count tables — by category, severity level, and language — sorted descending by count within each group +- Shows pagination warning for pull requests if more exist +- JSON output bundles all three API responses into a single object + +## Shared Formatting Utilities (`utils/formatting.ts`) + +Several helpers are shared between `repository.ts` and `pull-request.ts` via `utils/formatting.ts`: +- `printSection(title)` — bold section header +- `truncate(text, max)` — truncate with "..." suffix +- `colorMetric(value, threshold, mode)` — threshold-based coloring (max/min) +- `colorByGate(display, passing)` — green/red based on gate status +- `formatDelta(value, passing)` — +/- signed value with optional gate coloring +- `buildGateStatus(pr)` — maps `resultReasons` gate names to metric columns +- `formatStandards(pr)` — ✓/✗/- from quality + coverage `isUpToStandards` +- `formatPrCoverage(pr, passing)` — diffCoverage% (+/-deltaCoverage%) +- `formatPrIssues(pr, passing)` — +newIssues (colored by gate) / -fixedIssues (always gray) + +## issue command (`issue.ts`) + +- Takes ``, ``, ``, and `` (the numeric `resultDataId` shown on issue cards) as required arguments +- Fetches the issue, pattern info, and ±5 lines of file context in parallel +- **Default mode**: displays full issue detail — header, code context, false positive warning, pattern documentation +- **`--ignore` mode** (`-I`): calls `AnalysisService.updateIssueState` with `{ ignored: true, reason, comment }`; skips rendering issue details + - `-R, --ignore-reason`: `AcceptedUse` (default) | `FalsePositive` | `NotExploitable` | `TestCode` | `ExternalCode` + - `-m, --ignore-comment`: optional free-text comment +- **`--unignore` mode** (`-U`): calls `AnalysisService.updateIssueState` with `{ ignored: false }`; skips rendering issue details +- The API uses the string UUID (`issue.issueId`), not the numeric `resultDataId`, for the `updateIssueState` call + +## finding command (`finding.ts`) + +- Takes ``, ``, and `` (UUID shown on finding cards) as required arguments — **no `` argument** +- Fetches the security item; for Codacy-source findings, also fetches the linked quality issue, pattern, and file context +- Fetches CVE enrichment in parallel when the finding has a `cve` field +- **Default mode**: displays full finding detail — header, prose fields, code context (Codacy-source only), CVE block +- **`--ignore` mode** (`-I`): calls `SecurityService.ignoreSecurityItem` with `{ reason, comment }`; skips rendering finding details + - `-R, --ignore-reason`: `AcceptedUse` (default) | `FalsePositive` | `NotExploitable` | `TestCode` | `ExternalCode` + - `-m, --ignore-comment`: optional free-text comment +- **`--unignore` mode** (`-U`): calls `SecurityService.unignoreSecurityItem`; skips rendering finding details + +## pull-request command (`pull-request.ts`) + +- Takes ``, ``, ``, and `` as required arguments +- Action modes are mutually exclusive and checked in this order: `--ignore-issue`, `--ignore-all-false-positives`, `--unignore-issue`, `--issue`, `--diff`, default +- **Default mode**: calls four API endpoints in parallel: + - `getRepositoryPullRequest` — PR metadata + analysis summary + - `listPullRequestIssues` (status=new, onlyPotential=false) — new confirmed issues + - `listPullRequestIssues` (status=new, onlyPotential=true) — new potential issues + - `listPullRequestFiles` — files with metric deltas +- Displays a multi-section view: + - **About**: provider/org/repo, PR number + title, status, author, branches (origin → target), updated (friendly date), head commit SHA + - **Analysis**: analyzing status, up-to-standards (✓/✗ computed from quality + coverage), issues, coverage, complexity, duplication — all colored by gate status + - Gate failure/pass reasons shown inline next to the metric (e.g. "Fails <= 2 warning issues", "Fails <= 0 security issues") + - "To check" hints shown inline when a gate is configured but the metric has no data yet (e.g. "To check >= 50% coverage") + - Security gates (`securityIssueThreshold`) are handled explicitly — not falling through to generic formatting + - **Issues**: single merged list of confirmed + potential issues, card-style format (not a table), sorted by severity (Error > High > Warning > Info) + - Each card shows: colored severity | category + subcategory | POTENTIAL (if potential issue) + - Message, file:line, line content + - Severity colors: Error=red, High=orange (#FF8C00), Warning=yellow, Info=blue + - False positive detection: if `falsePositiveProbability >= falsePositiveThreshold`, shows "Potential false positive: {reason}" below line content + - **Files**: table showing only files with any metric delta change + - File path (truncated at 50), issues (new red, fixed green), coverage delta, complexity delta, duplication delta + - Zero values shown in gray without +/- sign; N/A also gray +- Shows pagination warnings for issues and files +- JSON output bundles PR data, new issues, potential issues, and files +- **`--ignore-issue ` mode** (`-I`): fetches all PR issues (new + potential, paginated), finds by `resultDataId`, calls `updateIssueState` with `{ ignored: true, reason, comment }` + - Supports `-R/--ignore-reason` (default: `AcceptedUse`) and `-m/--ignore-comment` +- **`--ignore-all-false-positives` mode** (`-F`): fetches all potential false positive issues (onlyPotential=true, paginated), ignores them all in parallel with hardcoded reason `FalsePositive`; supports `-m/--ignore-comment` +- **`--unignore-issue ` mode** (`-U`): same lookup as `--ignore-issue`, calls `updateIssueState` with `{ ignored: false }` +- **`--reanalyze` mode** (`-A`): fetches PR data to get `headCommitSha`, calls `RepositoryService.reanalyzeCommitById`; early return +- **Analysis status in About**: replaced "Head Commit" with "Analysis" row using `formatAnalysisStatus()` from `utils/formatting.ts`; fetches `getPullRequestCommits(limit=1)` and `listCoverageReports(limit=1)` in parallel with existing calls + +## JSON Output Filtering (`pickDeep`) + +All commands that output JSON now filter their response using `pickDeep(data, paths)` from `utils/output.ts`. This ensures the JSON output only includes fields that correspond to what's shown in the console table/card output. + +**Pattern for new commands:** +```typescript +if (format === "json") { + printJson(pickDeep(data, [ + "field.nested.path", + "another.field", + ])); + return; +} +``` + +**For arrays of items**, map each item through `pickDeep`: +```typescript +printJson(items.map((item: any) => pickDeep(item, [...]))); +``` + +**Special rules for JSON output:** +- Commit SHAs: include the full SHA (not truncated) +- Dates: include full ISO timestamps (not formatted) +- IDs: only include IDs already shown in console output diff --git a/src/commands/CLAUDE.md b/src/commands/CLAUDE.md index 99293e9..eef4bd2 100644 --- a/src/commands/CLAUDE.md +++ b/src/commands/CLAUDE.md @@ -1,200 +1 @@ -# Commands Design Decisions - -## Structure - -Each command is a single file that exports a `registerCommand(program: Command)` function. Commands are registered in `src/index.ts`. - -## Command Aliases - -Every command must declare a short alias via `.alias()`. Keep aliases short (2–4 characters) and intuitive: -- `repositories` → `repos` -- `repository` → `repo` -- `pull-request` → `pr` -- `issues` → `is` - -## Option Short Flags - -Every command option must have both a short flag and a long flag: `-X, --long-name `. Pick single letters that are intuitive and don't conflict with Commander's built-in flags (`-V/--version`, `-h/--help`) or the global `-o/--output` option. When the natural letter is already taken, use uppercase (e.g. `-O, --overview` instead of `-o`). - -## Option Naming: Singular vs Plural - -Use a **singular** option name when the parameter accepts a single value, and a **plural** name when it accepts a comma-separated list: - -- `--branch main` → singular (one branch) -- `--severities Critical,High` → plural (list of severity levels) -- `--categories Security,CodeStyle` → plural (list of categories) -- `--languages TypeScript,Python` → plural (list of languages) -- `--authors dev@example.com,other@example.com` → plural (list of emails) - -This applies to both the long flag name and the metavar: `--severities `, not `--severities `. - -## Output Format - -All commands support `--output json` via the global `-o, --output` option. Commands use `getOutputFormat(this)` (from `utils/output.ts`) to check the format and either: -- Render tables/styled output for `table` (default) -- Call `printJson(data)` for `json` - -The `this` context in Commander action handlers gives access to the command instance, which is used to read global options via `optsWithGlobals()`. - -## Table Styling - -Tables are created via `createTable()` from `utils/output.ts`, which applies default styling: -- No pipe borders — clean tabular format with space separators only -- Column headers are **bold white** (not the default red from cli-table3) - -## Pagination - -All commands that call paginated API endpoints must check for a cursor in the response and call `printPaginationWarning()` when more results exist. The warning includes a command-specific hint suggesting how to filter results: -- `info` (organizations): generic message ("Not all organizations are shown.") -- `repositories`: suggests `--search ` to filter by name - -New commands must follow this pattern: after printing the table, call `printPaginationWarning(response.pagination, "")`. - -## Date Formatting - -When displaying "Last Updated" or similar dates, use `formatFriendlyDate()` from `utils/output.ts`: -- Same day: relative time ("Just now", "10 minutes ago", "3 hours ago") -- Yesterday: "Yesterday" -- Older: "YYYY-MM-DD" - -## info command (`info.ts`) - -- Calls `AccountService.getUser()` and `AccountService.listUserOrganizations()` in parallel -- Displays user details in a key-value table (cli-table3 without headers) -- Displays organizations in a columnar table -- Provider codes (gh, gl, bb) are mapped to display names via `utils/providers.ts` -- Shows pagination warning if organizations response has more pages - -## Visibility Indicator - -Instead of a dedicated "Visibility" column (wastes horizontal space), public repositories are marked with a dimmed `⊙` (U+2299, circled dot operator) appended to the name. Private repositories show the name alone. This character is in the Mathematical Operators Unicode block and renders reliably across terminals. - -## repositories command (`repositories.ts`) - -- Takes `` and `` as required arguments -- Optional `--search ` passes through to the API's `search` parameter -- Public repos show `⊙` after the name instead of a separate Visibility column -- Quality metrics (complexity, duplication, coverage) are colored red/green based on `goals` thresholds from `RepositoryQualitySettings`: - - **Max thresholds** (issues, complexity, duplication): green if under, red if over - - **Min thresholds** (coverage): green if above, red if below -- Grade letters are colored: A/B = green, C = yellow, D/F = red -- Last Updated uses friendly date formatting (relative for today, "Yesterday", otherwise YYYY-MM-DD) -- Shows pagination warning if more results exist, suggesting `--search` to filter - -## repository command (`repository.ts`) - -- Takes ``, ``, and `` as required arguments -- Calls three API endpoints in parallel: `getRepositoryWithAnalysis`, `listRepositoryPullRequests`, `issuesOverview` -- Displays a multi-section dashboard: - - **About**: provider/org/name, visibility, default branch, last updated (friendly date), last analysis (time + short SHA) - - **Setup**: languages, coding standards, quality gate, problems (yellow if present, green "None" otherwise) - - **Metrics**: issues (count + per kLoC), coverage, complexity, duplication — colored by goals thresholds - - **Open Pull Requests**: filtered to open status, columns: - - `#`, `Title` (truncated at 50), `Branch` (truncated at 40) - - `✓` (header is a gray ✓) — green ✓ if `isUpToStandards` is true, red ✗ if false, dim `-` if undefined - - `Issues` — combined `+newIssues / -fixedIssues` - - `Coverage` — `diffCoverage% (+/-deltaCoverage%)` - - `Complexity` — delta value with +/- sign - - `Duplication` — delta clones count with +/- sign - - `Updated` — friendly date - - **Metric coloring via `resultReasons`**: The `quality.resultReasons` and `coverage.resultReasons` arrays contain per-gate pass/fail info (`gate` name + `isUpToStandards`). Gate names are matched by keyword to color the corresponding metric column: - - `"issue"` (not security) → Issues column - - `"coverage"` → Coverage column - - `"complexity"` → Complexity column - - `"duplication"` or `"clone"` → Duplication column - - Green if gate passes, red if gate fails; no coloring if no matching gate exists - - **Issues Overview**: three count tables — by category, severity level, and language — sorted descending by count within each group -- Shows pagination warning for pull requests if more exist -- JSON output bundles all three API responses into a single object - -## Shared Formatting Utilities (`utils/formatting.ts`) - -Several helpers are shared between `repository.ts` and `pull-request.ts` via `utils/formatting.ts`: -- `printSection(title)` — bold section header -- `truncate(text, max)` — truncate with "..." suffix -- `colorMetric(value, threshold, mode)` — threshold-based coloring (max/min) -- `colorByGate(display, passing)` — green/red based on gate status -- `formatDelta(value, passing)` — +/- signed value with optional gate coloring -- `buildGateStatus(pr)` — maps `resultReasons` gate names to metric columns -- `formatStandards(pr)` — ✓/✗/- from quality + coverage `isUpToStandards` -- `formatPrCoverage(pr, passing)` — diffCoverage% (+/-deltaCoverage%) -- `formatPrIssues(pr, passing)` — +newIssues (colored by gate) / -fixedIssues (always gray) - -## issue command (`issue.ts`) - -- Takes ``, ``, ``, and `` (the numeric `resultDataId` shown on issue cards) as required arguments -- Fetches the issue, pattern info, and ±5 lines of file context in parallel -- **Default mode**: displays full issue detail — header, code context, false positive warning, pattern documentation -- **`--ignore` mode** (`-I`): calls `AnalysisService.updateIssueState` with `{ ignored: true, reason, comment }`; skips rendering issue details - - `-R, --ignore-reason`: `AcceptedUse` (default) | `FalsePositive` | `NotExploitable` | `TestCode` | `ExternalCode` - - `-m, --ignore-comment`: optional free-text comment -- **`--unignore` mode** (`-U`): calls `AnalysisService.updateIssueState` with `{ ignored: false }`; skips rendering issue details -- The API uses the string UUID (`issue.issueId`), not the numeric `resultDataId`, for the `updateIssueState` call - -## finding command (`finding.ts`) - -- Takes ``, ``, and `` (UUID shown on finding cards) as required arguments — **no `` argument** -- Fetches the security item; for Codacy-source findings, also fetches the linked quality issue, pattern, and file context -- Fetches CVE enrichment in parallel when the finding has a `cve` field -- **Default mode**: displays full finding detail — header, prose fields, code context (Codacy-source only), CVE block -- **`--ignore` mode** (`-I`): calls `SecurityService.ignoreSecurityItem` with `{ reason, comment }`; skips rendering finding details - - `-R, --ignore-reason`: `AcceptedUse` (default) | `FalsePositive` | `NotExploitable` | `TestCode` | `ExternalCode` - - `-m, --ignore-comment`: optional free-text comment -- **`--unignore` mode** (`-U`): calls `SecurityService.unignoreSecurityItem`; skips rendering finding details - -## pull-request command (`pull-request.ts`) - -- Takes ``, ``, ``, and `` as required arguments -- Action modes are mutually exclusive and checked in this order: `--ignore-issue`, `--ignore-all-false-positives`, `--unignore-issue`, `--issue`, `--diff`, default -- **Default mode**: calls four API endpoints in parallel: - - `getRepositoryPullRequest` — PR metadata + analysis summary - - `listPullRequestIssues` (status=new, onlyPotential=false) — new confirmed issues - - `listPullRequestIssues` (status=new, onlyPotential=true) — new potential issues - - `listPullRequestFiles` — files with metric deltas -- Displays a multi-section view: - - **About**: provider/org/repo, PR number + title, status, author, branches (origin → target), updated (friendly date), head commit SHA - - **Analysis**: analyzing status, up-to-standards (✓/✗ computed from quality + coverage), issues, coverage, complexity, duplication — all colored by gate status - - Gate failure/pass reasons shown inline next to the metric (e.g. "Fails <= 2 warning issues", "Fails <= 0 security issues") - - "To check" hints shown inline when a gate is configured but the metric has no data yet (e.g. "To check >= 50% coverage") - - Security gates (`securityIssueThreshold`) are handled explicitly — not falling through to generic formatting - - **Issues**: single merged list of confirmed + potential issues, card-style format (not a table), sorted by severity (Error > High > Warning > Info) - - Each card shows: colored severity | category + subcategory | POTENTIAL (if potential issue) - - Message, file:line, line content - - Severity colors: Error=red, High=orange (#FF8C00), Warning=yellow, Info=blue - - False positive detection: if `falsePositiveProbability >= falsePositiveThreshold`, shows "Potential false positive: {reason}" below line content - - **Files**: table showing only files with any metric delta change - - File path (truncated at 50), issues (new red, fixed green), coverage delta, complexity delta, duplication delta - - Zero values shown in gray without +/- sign; N/A also gray -- Shows pagination warnings for issues and files -- JSON output bundles PR data, new issues, potential issues, and files -- **`--ignore-issue ` mode** (`-I`): fetches all PR issues (new + potential, paginated), finds by `resultDataId`, calls `updateIssueState` with `{ ignored: true, reason, comment }` - - Supports `-R/--ignore-reason` (default: `AcceptedUse`) and `-m/--ignore-comment` -- **`--ignore-all-false-positives` mode** (`-F`): fetches all potential false positive issues (onlyPotential=true, paginated), ignores them all in parallel with hardcoded reason `FalsePositive`; supports `-m/--ignore-comment` -- **`--unignore-issue ` mode** (`-U`): same lookup as `--ignore-issue`, calls `updateIssueState` with `{ ignored: false }` -- **`--reanalyze` mode** (`-A`): fetches PR data to get `headCommitSha`, calls `RepositoryService.reanalyzeCommitById`; early return -- **Analysis status in About**: replaced "Head Commit" with "Analysis" row using `formatAnalysisStatus()` from `utils/formatting.ts`; fetches `getPullRequestCommits(limit=1)` and `listCoverageReports(limit=1)` in parallel with existing calls - -## JSON Output Filtering (`pickDeep`) - -All commands that output JSON now filter their response using `pickDeep(data, paths)` from `utils/output.ts`. This ensures the JSON output only includes fields that correspond to what's shown in the console table/card output. - -**Pattern for new commands:** -```typescript -if (format === "json") { - printJson(pickDeep(data, [ - "field.nested.path", - "another.field", - ])); - return; -} -``` - -**For arrays of items**, map each item through `pickDeep`: -```typescript -printJson(items.map((item: any) => pickDeep(item, [...]))); -``` - -**Special rules for JSON output:** -- Commit SHAs: include the full SHA (not truncated) -- Dates: include full ISO timestamps (not formatted) -- IDs: only include IDs already shown in console output +@AGENTS.md \ No newline at end of file