diff --git a/README.md b/README.md index e793e5e..dd38bdd 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ All output is structured JSON, making it ideal for programmatic consumption by A ## Installation ```bash -npm install -g lucas-cli +npm install -g lucasapp-cli ``` Or run directly with Bun: @@ -34,6 +34,7 @@ lucas transactions list --from 2026-03-01 # Get a financial summary lucas stats summary +lucas stats summary --year 2026 --month 3 ``` ## Commands @@ -192,8 +193,16 @@ lucas loans delete # Delete loan ```bash lucas stats summary # Financial summary +lucas stats summary --year 2026 --month 3 # Inspect a specific month lucas stats monthly # Monthly statistics -lucas stats by-category # Spending by category +lucas stats by-category --year 2026 --month 3 # Spending by category +``` + +When a newer CLI version is available, interactive terminals will show a short +update notice with the recommended command: + +```bash +npm install -g lucasapp-cli@latest ``` ### Categories diff --git a/package.json b/package.json index 9275296..b660b9d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lucasapp-cli", - "version": "0.3.0", + "version": "0.3.1", "description": "LucasApp CLI - Financial data management for AI agents", "author": "StevenACZ", "license": "MIT", diff --git a/src/commands/stats/by-category.ts b/src/commands/stats/by-category.ts index 3cb0a82..a518af6 100644 --- a/src/commands/stats/by-category.ts +++ b/src/commands/stats/by-category.ts @@ -1,13 +1,30 @@ import { Command } from "commander"; import { apiRequest } from "../../lib/api-client.js"; +import { setOptionalIntegerQueryParam } from "../../lib/query-params.js"; import { output } from "../../lib/output.js"; export const byCategoryCommand = new Command("by-category") .description("Get statistics by category") .option("--currency ", "Currency code") + .option("--year ", "Target year") + .option("--month ", "Target month (1-12)") .action(async (opts) => { const params: Record = {}; if (opts.currency) params.currency = opts.currency; + setOptionalIntegerQueryParam(params, { + value: opts.year, + flag: "--year", + queryKey: "year", + min: 2000, + max: 2100, + }); + setOptionalIntegerQueryParam(params, { + value: opts.month, + flag: "--month", + queryKey: "month", + min: 1, + max: 12, + }); const data = await apiRequest( "GET", "/api/stats/by-category", diff --git a/src/commands/stats/summary.ts b/src/commands/stats/summary.ts index c258de5..8c9a995 100644 --- a/src/commands/stats/summary.ts +++ b/src/commands/stats/summary.ts @@ -1,13 +1,30 @@ import { Command } from "commander"; import { apiRequest } from "../../lib/api-client.js"; +import { setOptionalIntegerQueryParam } from "../../lib/query-params.js"; import { output } from "../../lib/output.js"; export const summaryCommand = new Command("summary") .description("Get financial summary") .option("--currency ", "Currency code") + .option("--year ", "Target year") + .option("--month ", "Target month (1-12)") .action(async (opts) => { const params: Record = {}; if (opts.currency) params.currency = opts.currency; + setOptionalIntegerQueryParam(params, { + value: opts.year, + flag: "--year", + queryKey: "year", + min: 2000, + max: 2100, + }); + setOptionalIntegerQueryParam(params, { + value: opts.month, + flag: "--month", + queryKey: "month", + min: 1, + max: 12, + }); const data = await apiRequest( "GET", "/api/stats/summary", diff --git a/src/commands/transactions/list.ts b/src/commands/transactions/list.ts index 9093f3a..7c1fbbb 100644 --- a/src/commands/transactions/list.ts +++ b/src/commands/transactions/list.ts @@ -13,13 +13,13 @@ export const listTransactionsCommand = new Command("list") .option("--take ", "Take N records") .action(async (opts) => { const params: Record = {}; - if (opts.from) params.from = opts.from; - if (opts.to) params.to = opts.to; + if (opts.from) params.startDate = opts.from; + if (opts.to) params.endDate = opts.to; if (opts.categoryId) params.categoryId = opts.categoryId; if (opts.accountId) params.accountId = opts.accountId; if (opts.type) params.type = opts.type; - if (opts.skip) params.skip = opts.skip; - if (opts.take) params.take = opts.take; + if (opts.skip) params.offset = opts.skip; + if (opts.take) params.limit = opts.take; const data = await apiRequest( "GET", diff --git a/src/index.ts b/src/index.ts index 9b86005..2d7242e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,7 @@ #!/usr/bin/env node import { Command } from "commander"; +import { maybeNotifyForUpdate } from "./lib/update-notifier.js"; +import { CLI_VERSION } from "./lib/version.js"; // Auth import { loginCommand } from "./commands/auth/login.js"; @@ -55,7 +57,7 @@ const program = new Command(); program .name("lucas") .description("LucasApp CLI - Financial data management for AI agents") - .version("0.3.0"); + .version(CLI_VERSION); // Grupo: auth const auth = program.command("auth").description("Authentication commands"); @@ -123,4 +125,5 @@ const exchangeRate = program .description("Currency exchange"); exchangeRate.addCommand(convertCommand); -program.parse(); +await maybeNotifyForUpdate(CLI_VERSION); +await program.parseAsync(process.argv); diff --git a/src/lib/config.ts b/src/lib/config.ts index 4593dca..492d6c5 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -16,7 +16,7 @@ export interface Credentials { expiresAt: string; } -const CONFIG_DIR = join(homedir(), ".config", "lucas"); +export const CONFIG_DIR = join(homedir(), ".config", "lucas"); const CREDENTIALS_FILE = join(CONFIG_DIR, "credentials.json"); const DEFAULT_API_URL = "https://lucas.stevenacz.com"; diff --git a/src/lib/query-params.ts b/src/lib/query-params.ts new file mode 100644 index 0000000..16e507a --- /dev/null +++ b/src/lib/query-params.ts @@ -0,0 +1,25 @@ +import { output } from "./output.js"; +import { parseOptionalNumber } from "./number-parser.js"; + +export function setOptionalIntegerQueryParam( + params: Record, + input: { + value: unknown; + flag: string; + queryKey: string; + min: number; + max: number; + }, +): void { + const parsed = parseOptionalNumber(input.value, input.flag); + if (parsed === undefined) return; + + if (!Number.isInteger(parsed) || parsed < input.min || parsed > input.max) { + output.error(`Invalid value for ${input.flag}`, 400, { + value: input.value, + expected: `${input.min}-${input.max}`, + }); + } + + params[input.queryKey] = String(parsed); +} diff --git a/src/lib/update-notifier.ts b/src/lib/update-notifier.ts new file mode 100644 index 0000000..5071094 --- /dev/null +++ b/src/lib/update-notifier.ts @@ -0,0 +1,124 @@ +import { existsSync, readFileSync, writeFileSync } from "fs"; +import { join } from "path"; +import { CONFIG_DIR, ensureConfigDir } from "./config.js"; + +const UPDATE_CACHE_FILE = join(CONFIG_DIR, "update-check.json"); +const UPDATE_CHECK_INTERVAL_MS = 1000 * 60 * 60 * 12; +const PACKAGE_NAME = "lucasapp-cli"; + +interface UpdateCache { + lastCheckedAt: string; + latestVersion?: string; +} + +export function parseVersion(version: string): number[] { + return version + .trim() + .replace(/^v/, "") + .split(".") + .map((part) => Number.parseInt(part, 10) || 0); +} + +export function isVersionNewer( + currentVersion: string, + latestVersion: string, +): boolean { + const current = parseVersion(currentVersion); + const latest = parseVersion(latestVersion); + const length = Math.max(current.length, latest.length); + + for (let index = 0; index < length; index += 1) { + const currentPart = current[index] ?? 0; + const latestPart = latest[index] ?? 0; + + if (latestPart > currentPart) return true; + if (latestPart < currentPart) return false; + } + + return false; +} + +export function shouldRefreshUpdateCheck( + cache: UpdateCache | null, + nowMs: number = Date.now(), +): boolean { + if (!cache?.lastCheckedAt) return true; + + const lastCheckedAtMs = new Date(cache.lastCheckedAt).getTime(); + if (!Number.isFinite(lastCheckedAtMs)) return true; + + return nowMs - lastCheckedAtMs >= UPDATE_CHECK_INTERVAL_MS; +} + +function isInteractiveTerminal(): boolean { + return ( + Boolean(process.stdout.isTTY && process.stderr.isTTY) && + process.env.CI !== "true" && + process.env.LUCAS_DISABLE_UPDATE_NOTIFIER !== "1" + ); +} + +function loadUpdateCache(): UpdateCache | null { + if (!existsSync(UPDATE_CACHE_FILE)) return null; + + try { + return JSON.parse(readFileSync(UPDATE_CACHE_FILE, "utf-8")) as UpdateCache; + } catch { + return null; + } +} + +function saveUpdateCache(cache: UpdateCache): void { + ensureConfigDir(); + writeFileSync(UPDATE_CACHE_FILE, JSON.stringify(cache, null, 2)); +} + +async function fetchLatestVersion(): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 1500); + + try { + const response = await fetch( + `https://registry.npmjs.org/${PACKAGE_NAME}/latest`, + { + signal: controller.signal, + headers: { + Accept: "application/json", + }, + }, + ); + + if (!response.ok) return undefined; + + const payload = (await response.json()) as { version?: unknown }; + return typeof payload.version === "string" ? payload.version : undefined; + } catch { + return undefined; + } finally { + clearTimeout(timeout); + } +} + +export async function maybeNotifyForUpdate( + currentVersion: string, +): Promise { + if (!isInteractiveTerminal()) return; + + const cache = loadUpdateCache(); + let latestVersion = cache?.latestVersion; + + if (shouldRefreshUpdateCheck(cache)) { + latestVersion = await fetchLatestVersion(); + saveUpdateCache({ + lastCheckedAt: new Date().toISOString(), + ...(latestVersion && { latestVersion }), + }); + } + + if (!latestVersion || !isVersionNewer(currentVersion, latestVersion)) return; + + console.error( + `Update available for lucas: ${currentVersion} -> ${latestVersion}`, + ); + console.error("Run: npm install -g lucasapp-cli@latest"); +} diff --git a/src/lib/version.ts b/src/lib/version.ts new file mode 100644 index 0000000..19f62a1 --- /dev/null +++ b/src/lib/version.ts @@ -0,0 +1 @@ +export const CLI_VERSION = "0.3.1"; diff --git a/tests/commands/stats.test.ts b/tests/commands/stats.test.ts new file mode 100644 index 0000000..b7b812f --- /dev/null +++ b/tests/commands/stats.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const apiRequest = vi.fn(); +const outputSuccess = vi.fn(); +const outputError = vi.fn((message: string) => { + throw new Error(message); +}); + +vi.mock("../../src/lib/api-client.js", () => ({ + apiRequest, +})); + +vi.mock("../../src/lib/output.js", () => ({ + output: { + success: outputSuccess, + error: outputError, + }, +})); + +const { summaryCommand } = await import("../../src/commands/stats/summary.js"); + +describe("stats commands", () => { + beforeEach(() => { + apiRequest.mockReset(); + outputSuccess.mockReset(); + outputError.mockClear(); + }); + + it("passes optional month and year to stats summary", async () => { + apiRequest.mockResolvedValue({}); + + await summaryCommand.parseAsync( + ["--year", "2026", "--month", "3", "--currency", "PEN"], + { from: "user" }, + ); + + expect(apiRequest).toHaveBeenCalledWith( + "GET", + "/api/stats/summary", + undefined, + { + currency: "PEN", + year: "2026", + month: "3", + }, + ); + }); + + it("rejects invalid months for stats summary", async () => { + apiRequest.mockResolvedValue({}); + + await expect( + summaryCommand.parseAsync(["--month", "13"], { from: "user" }), + ).rejects.toThrow("Invalid value for --month"); + }); +}); diff --git a/tests/commands/transactions-list.test.ts b/tests/commands/transactions-list.test.ts new file mode 100644 index 0000000..2876c76 --- /dev/null +++ b/tests/commands/transactions-list.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const apiRequest = vi.fn(); +const outputSuccess = vi.fn(); + +vi.mock("../../src/lib/api-client.js", () => ({ + apiRequest, +})); + +vi.mock("../../src/lib/output.js", () => ({ + output: { + success: outputSuccess, + error: vi.fn(), + }, +})); + +const { listTransactionsCommand } = + await import("../../src/commands/transactions/list.js"); + +describe("transactions list command", () => { + beforeEach(() => { + apiRequest.mockReset(); + outputSuccess.mockReset(); + }); + + it("maps legacy CLI flags to canonical transaction filter query params", async () => { + apiRequest.mockResolvedValue([]); + + await listTransactionsCommand.parseAsync( + [ + "--from", + "2026-04-01", + "--to", + "2026-04-30", + "--skip", + "5", + "--take", + "10", + ], + { from: "user" }, + ); + + expect(apiRequest).toHaveBeenCalledWith( + "GET", + "/api/transactions", + undefined, + { + startDate: "2026-04-01", + endDate: "2026-04-30", + offset: "5", + limit: "10", + }, + ); + }); +}); diff --git a/tests/lib/update-notifier.test.ts b/tests/lib/update-notifier.test.ts new file mode 100644 index 0000000..b2bbe2f --- /dev/null +++ b/tests/lib/update-notifier.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { + isVersionNewer, + parseVersion, + shouldRefreshUpdateCheck, +} from "../../src/lib/update-notifier.js"; + +describe("update notifier", () => { + it("parses semantic versions defensively", () => { + expect(parseVersion("v0.3.1")).toEqual([0, 3, 1]); + expect(parseVersion("1.2")).toEqual([1, 2]); + }); + + it("detects when a newer version exists", () => { + expect(isVersionNewer("0.3.0", "0.3.1")).toBe(true); + expect(isVersionNewer("0.3.1", "0.3.1")).toBe(false); + expect(isVersionNewer("0.3.2", "0.3.1")).toBe(false); + }); + + it("refreshes update checks only after the cache expires", () => { + expect(shouldRefreshUpdateCheck(null, 0)).toBe(true); + expect( + shouldRefreshUpdateCheck( + { lastCheckedAt: new Date(0).toISOString(), latestVersion: "0.3.1" }, + 1000, + ), + ).toBe(false); + expect( + shouldRefreshUpdateCheck( + { lastCheckedAt: new Date(0).toISOString(), latestVersion: "0.3.1" }, + 1000 * 60 * 60 * 12 + 1, + ), + ).toBe(true); + }); +});