From 91a4c59a624d8540f27c4887855e1f11b61e068e Mon Sep 17 00:00:00 2001 From: Fabiano Cruz Date: Fri, 5 Jun 2026 21:58:14 -0300 Subject: [PATCH] =?UTF-8?q?fix(cli):=20close=20the=20@codespar/cli=20audit?= =?UTF-8?q?=20=E2=80=94=20project=20scoping,=20masked=20login,=20timeouts,?= =?UTF-8?q?=20ledger/issue,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings fixed: - SECURITY — the `login` prompt echoed the API key in cleartext. Now masked (suppress the readline echo on a TTY; plain read off a pipe). - Project scoping was entirely non-functional: config.project was loaded but never used. Wired everywhere via a single resolveAuth() — the raw ApiClient sends x-codespar-project, SDK commands pass projectId, the logs SSE carries the header, and a `--project` flag is added. Multi- project orgs no longer silently hit the org default. - ApiClient had no request timeout (a hung server hung the CLI). Added a 30s AbortController timeout surfaced as a clear CliError. - Missing `ledger` + `issue` commands for the SDK 0.10 meta-tools. Added (mirror charge/ship) with validation + a shared meta-input helper. - Stale User-Agent (0.1.0) + duplicated VERSION. Single version.ts source. - Non-`--json` double-print (human summary to stderr + full JSON dump to stdout) on charge/ship/payment-status/verification-status — dropped the redundant dump. execute/tools keep theirs (that IS their data/schema output, not a duplicate). - No tests. Added 19 unit tests (ApiClient header/timeout/error/204 via a mocked fetch; ledger/issue validation; meta-input parsing) + a `test` script; tests excluded from the published dist. Gate: turbo build + typecheck + test 54/54 green (CLI tests 19/19). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/package.json | 1 + packages/cli/src/__tests__/api.test.ts | 74 +++++++++++ packages/cli/src/__tests__/meta-tools.test.ts | 95 +++++++++++++ packages/cli/src/api.ts | 45 ++++++- packages/cli/src/commands/charge.ts | 4 +- packages/cli/src/commands/discover.ts | 3 +- packages/cli/src/commands/execute.ts | 3 +- packages/cli/src/commands/issue.ts | 73 ++++++++++ packages/cli/src/commands/ledger.ts | 77 +++++++++++ packages/cli/src/commands/login.ts | 37 +++++- packages/cli/src/commands/logs.ts | 5 +- packages/cli/src/commands/meta-input.ts | 37 ++++++ packages/cli/src/commands/payment-status.ts | 4 +- packages/cli/src/commands/ship.ts | 4 +- .../cli/src/commands/verification-status.ts | 4 +- packages/cli/src/commands/wizard.ts | 3 +- packages/cli/src/index.ts | 125 ++++++++++-------- packages/cli/src/version.ts | 7 + packages/cli/tsconfig.json | 2 +- 19 files changed, 522 insertions(+), 81 deletions(-) create mode 100644 packages/cli/src/__tests__/api.test.ts create mode 100644 packages/cli/src/__tests__/meta-tools.test.ts create mode 100644 packages/cli/src/commands/issue.ts create mode 100644 packages/cli/src/commands/ledger.ts create mode 100644 packages/cli/src/commands/meta-input.ts create mode 100644 packages/cli/src/version.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 429363f..998018f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -17,6 +17,7 @@ "dev": "tsc --watch", "typecheck": "tsc --noEmit", "clean": "rm -rf dist", + "test": "vitest run", "prepublishOnly": "npm run build" }, "keywords": [ diff --git a/packages/cli/src/__tests__/api.test.ts b/packages/cli/src/__tests__/api.test.ts new file mode 100644 index 0000000..53ec855 --- /dev/null +++ b/packages/cli/src/__tests__/api.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { ApiClient } from "../api.js"; + +type Init = { headers: Record; signal?: AbortSignal }; + +function mockFetch(impl: (url: URL, init: Init) => Response | Promise) { + return vi + .spyOn(globalThis, "fetch") + .mockImplementation((input: unknown, init: unknown) => + Promise.resolve(impl(input as URL, init as Init)), + ); +} + +afterEach(() => vi.restoreAllMocks()); + +describe("ApiClient", () => { + it("sends Authorization + a versioned User-Agent + x-codespar-project when project is set", async () => { + let captured: Record = {}; + mockFetch((_url, init) => { + captured = init.headers; + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }); + const client = new ApiClient({ + apiKey: "csk_test_x", + baseUrl: "https://api.x.dev", + project: "prj_abc", + }); + await client.get("/v1/whoami"); + expect(captured["Authorization"]).toBe("Bearer csk_test_x"); + expect(captured["x-codespar-project"]).toBe("prj_abc"); + expect(captured["User-Agent"]).toMatch(/^codespar-cli\/\d+\.\d+\.\d+$/); + }); + + it("omits x-codespar-project when no project is configured", async () => { + let captured: Record = {}; + mockFetch((_url, init) => { + captured = init.headers; + return new Response("{}", { status: 200 }); + }); + await new ApiClient({ apiKey: "csk_test_x", baseUrl: "https://api.x.dev" }).get("/v1/whoami"); + expect(captured["x-codespar-project"]).toBeUndefined(); + }); + + it("throws a CliError carrying the server's error detail on a non-2xx", async () => { + mockFetch(() => new Response(JSON.stringify({ message: "bad key" }), { status: 401 })); + const client = new ApiClient({ apiKey: "csk_test_x", baseUrl: "https://api.x.dev" }); + await expect(client.get("/v1/whoami")).rejects.toThrow(/401: bad key/); + }); + + it("returns undefined on 204 No Content", async () => { + mockFetch(() => new Response(null, { status: 204 })); + const client = new ApiClient({ apiKey: "csk_test_x", baseUrl: "https://api.x.dev" }); + await expect(client.delete("/v1/sessions/s_1")).resolves.toBeUndefined(); + }); + + it("aborts and surfaces a timeout CliError when the server hangs", async () => { + mockFetch( + (_url, init) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener("abort", () => { + const err = new Error("aborted"); + err.name = "AbortError"; + reject(err); + }); + }), + ); + const client = new ApiClient({ + apiKey: "csk_test_x", + baseUrl: "https://api.x.dev", + timeoutMs: 10, + }); + await expect(client.get("/v1/whoami")).rejects.toThrow(/timed out after 10ms/); + }); +}); diff --git a/packages/cli/src/__tests__/meta-tools.test.ts b/packages/cli/src/__tests__/meta-tools.test.ts new file mode 100644 index 0000000..e6b2138 --- /dev/null +++ b/packages/cli/src/__tests__/meta-tools.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from "vitest"; +import type { IssueArgs, LedgerArgs } from "@codespar/sdk"; +import { validateLedgerArgs } from "../commands/ledger.js"; +import { validateIssueArgs } from "../commands/issue.js"; +import { resolveMetaInput } from "../commands/meta-input.js"; + +describe("validateLedgerArgs", () => { + it("rejects an unknown action", () => { + expect(() => validateLedgerArgs({ action: "nope" } as unknown as LedgerArgs)).toThrow( + /action must be one of/, + ); + }); + + it("requires asset + non-empty source + destination for an entry", () => { + expect(() => validateLedgerArgs({ action: "entry" })).toThrow(/asset is required/); + expect(() => validateLedgerArgs({ action: "entry", asset: "BRL" })).toThrow(/source/); + expect(() => + validateLedgerArgs({ action: "entry", asset: "BRL", source: [{ account: "a", amount: 1 }] }), + ).toThrow(/destination/); + }); + + it("requires an account id for a balance read", () => { + expect(() => validateLedgerArgs({ action: "balance" })).toThrow(/account/); + }); + + it("accepts a well-formed entry", () => { + expect(() => + validateLedgerArgs({ + action: "entry", + asset: "BRL", + source: [{ account: "@external/BRL", amount: 100 }], + destination: [{ account: "@wallet/u", amount: 100 }], + }), + ).not.toThrow(); + }); +}); + +describe("validateIssueArgs", () => { + it("rejects an unknown action", () => { + expect(() => validateIssueArgs({ action: "nope" } as unknown as IssueArgs)).toThrow( + /action must be one of/, + ); + }); + + it("requires cardholder_id + program_id to issue a card", () => { + expect(() => validateIssueArgs({ action: "card-virtual" })).toThrow(/cardholder_id/); + expect(() => validateIssueArgs({ action: "card-virtual", cardholder_id: "u" })).toThrow( + /program_id/, + ); + }); + + it("requires card_id for control + get", () => { + expect(() => validateIssueArgs({ action: "card-get" })).toThrow(/card_id/); + }); + + it("requires a control verb for card-control", () => { + expect(() => validateIssueArgs({ action: "card-control", card_id: "c" })).toThrow(/control/); + }); + + it("requires a shipping_address for a physical card", () => { + expect(() => + validateIssueArgs({ action: "card-physical", cardholder_id: "u", program_id: "p" }), + ).toThrow(/shipping_address/); + }); +}); + +describe("resolveMetaInput", () => { + it("rejects passing both --input and --input-file", async () => { + await expect( + resolveMetaInput({ input: "{}", inputFile: "x.json" }, "ledger", "ex"), + ).rejects.toThrow(/either/); + }); + + it("rejects passing neither", async () => { + await expect(resolveMetaInput({}, "ledger", "ex")).rejects.toThrow(/requires --input/); + }); + + it("rejects invalid JSON", async () => { + await expect(resolveMetaInput({ input: "{bad" }, "ledger", "ex")).rejects.toThrow( + /not valid JSON/, + ); + }); + + it("rejects a non-object (array / scalar) body", async () => { + await expect(resolveMetaInput({ input: "[]" }, "ledger", "ex")).rejects.toThrow( + /must be a JSON object/, + ); + }); + + it("parses a valid JSON object", async () => { + await expect(resolveMetaInput({ input: '{"action":"entry"}' }, "ledger", "ex")).resolves.toEqual( + { action: "entry" }, + ); + }); +}); diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index a20f992..63cfb1b 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -1,4 +1,17 @@ -import { CliError, type CliConfig } from "./config.js"; +import { CliError } from "./config.js"; +import { VERSION } from "./version.js"; + +export interface ApiClientConfig { + apiKey: string; + baseUrl: string; + /** Resolved project. When set, every request carries `x-codespar-project` + * so multi-project orgs scope to the right project (without it the org + * default is used server-side). */ + project?: string; + /** Per-request timeout in ms. Default 30s. Streaming commands (logs tail, + * payment-status --stream) use their own long-lived path, not this. */ + timeoutMs?: number; +} /** * Thin fetch wrapper around the CodeSpar REST API. The SDK doesn't expose @@ -6,7 +19,11 @@ import { CliError, type CliConfig } from "./config.js"; * so we hit the HTTP surface directly with the user's API key. */ export class ApiClient { - constructor(private readonly config: Required>) {} + private readonly timeoutMs: number; + + constructor(private readonly config: ApiClientConfig) { + this.timeoutMs = config.timeoutMs ?? 30_000; + } async get(path: string, query?: Record): Promise { return this.request("GET", path, undefined, query); @@ -33,21 +50,35 @@ export class ApiClient { } } + const headers: Record = { + Authorization: `Bearer ${this.config.apiKey}`, + "Content-Type": "application/json", + "User-Agent": `codespar-cli/${VERSION}`, + }; + if (this.config.project) headers["x-codespar-project"] = this.config.project; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + let res: Response; try { res = await fetch(url, { method, - headers: { - Authorization: `Bearer ${this.config.apiKey}`, - "Content-Type": "application/json", - "User-Agent": "codespar-cli/0.1.0", - }, + headers, body: body !== undefined ? JSON.stringify(body) : undefined, + signal: controller.signal, }); } catch (err) { + if ((err as Error).name === "AbortError") { + throw new CliError( + `Request to ${method} ${url.pathname} timed out after ${this.timeoutMs}ms.`, + ); + } throw new CliError( `Network error calling ${method} ${url.pathname}: ${(err as Error).message}`, ); + } finally { + clearTimeout(timer); } if (!res.ok) { diff --git a/packages/cli/src/commands/charge.ts b/packages/cli/src/commands/charge.ts index cfdbc98..99936d5 100644 --- a/packages/cli/src/commands/charge.ts +++ b/packages/cli/src/commands/charge.ts @@ -7,6 +7,7 @@ import { info, json, success } from "../output.js"; interface ChargeCommandOptions { apiKey: string; baseUrl: string; + project?: string; user?: string; input?: string; inputFile?: string; @@ -23,7 +24,7 @@ export async function chargeCommand(opts: ChargeCommandOptions): Promise { validateChargeArgs(args); const userId = opts.user ?? "cli-user"; - const cs = new CodeSpar({ apiKey: opts.apiKey, baseUrl: opts.baseUrl }); + const cs = new CodeSpar({ apiKey: opts.apiKey, baseUrl: opts.baseUrl, projectId: opts.project }); const session = await cs.create(userId, { servers: [] }); try { @@ -44,7 +45,6 @@ export async function chargeCommand(opts: ChargeCommandOptions): Promise { info("Pix copy-paste:"); process.stdout.write(`\n${result.pix_copy_paste}\n\n`); } - process.stdout.write(JSON.stringify(result, null, 2) + "\n"); } finally { await session.close(); } diff --git a/packages/cli/src/commands/discover.ts b/packages/cli/src/commands/discover.ts index 6c6df8e..549854e 100644 --- a/packages/cli/src/commands/discover.ts +++ b/packages/cli/src/commands/discover.ts @@ -6,6 +6,7 @@ import { c, info, json, table } from "../output.js"; interface DiscoverCommandOptions { apiKey: string; baseUrl: string; + project?: string; user?: string; category?: string; country?: string; @@ -34,7 +35,7 @@ export async function discoverCommand( } const userId = opts.user ?? "cli-user"; - const cs = new CodeSpar({ apiKey: opts.apiKey, baseUrl: opts.baseUrl }); + const cs = new CodeSpar({ apiKey: opts.apiKey, baseUrl: opts.baseUrl, projectId: opts.project }); const session = await cs.create(userId, { servers: [] }); try { diff --git a/packages/cli/src/commands/execute.ts b/packages/cli/src/commands/execute.ts index 2253f8c..63ecfc9 100644 --- a/packages/cli/src/commands/execute.ts +++ b/packages/cli/src/commands/execute.ts @@ -10,6 +10,7 @@ interface ExecuteOptions { user?: string; apiKey: string; baseUrl: string; + project?: string; json?: boolean; } @@ -24,7 +25,7 @@ export async function executeCommand(toolName: string, opts: ExecuteOptions): Pr const input = await resolveInput(opts); const userId = opts.user ?? "cli-user"; - const cs = new CodeSpar({ apiKey: opts.apiKey, baseUrl: opts.baseUrl }); + const cs = new CodeSpar({ apiKey: opts.apiKey, baseUrl: opts.baseUrl, projectId: opts.project }); const session = await cs.create(userId, { servers: [opts.server] }); try { diff --git a/packages/cli/src/commands/issue.ts b/packages/cli/src/commands/issue.ts new file mode 100644 index 0000000..470f41d --- /dev/null +++ b/packages/cli/src/commands/issue.ts @@ -0,0 +1,73 @@ +import { CodeSpar } from "@codespar/sdk"; +import type { IssueArgs, IssueResult } from "@codespar/sdk"; +import { CliError } from "../config.js"; +import { info, json, success } from "../output.js"; +import { resolveMetaInput } from "./meta-input.js"; + +interface IssueCommandOptions { + apiKey: string; + baseUrl: string; + project?: string; + user?: string; + input?: string; + inputFile?: string; + json?: boolean; +} + +const EXAMPLE = + '{"action":"card-virtual","cardholder_id":"usr_123","program_id":"afg_123"}'; + +/** + * Wraps `session.issue(args)` — issue a virtual/physical agent spend card, + * freeze/unfreeze/cancel one, or read a card's status (Pomelo). The + * meta-tool router resolves the rail, so no `--server`. + */ +export async function issueCommand(opts: IssueCommandOptions): Promise { + const args = (await resolveMetaInput(opts, "issue", EXAMPLE)) as unknown as IssueArgs; + validateIssueArgs(args); + + const userId = opts.user ?? "cli-user"; + const cs = new CodeSpar({ + apiKey: opts.apiKey, + baseUrl: opts.baseUrl, + projectId: opts.project, + }); + const session = await cs.create(userId, { servers: [] }); + + try { + const result: IssueResult = await session.issue(args); + + if (opts.json) { + json(result); + return; + } + + success(`issue ${args.action} → ${result.status ?? "ok"}`); + if (result.id) info(`Card id: ${result.id}`); + if (result.card_type) info(`Type: ${result.card_type}`); + if (result.last_four) info(`Last four: ${result.last_four}`); + if (result.cardholder_id) info(`Cardholder: ${result.cardholder_id}`); + } finally { + await session.close(); + } +} + +export function validateIssueArgs(args: IssueArgs): void { + const actions = ["card-virtual", "card-physical", "card-control", "card-get"]; + if (!args.action || !actions.includes(args.action)) { + throw new CliError(`issue.action must be one of: ${actions.join(", ")}.`); + } + if (args.action === "card-virtual" || args.action === "card-physical") { + if (!args.cardholder_id) throw new CliError("issue.cardholder_id is required to issue a card."); + if (!args.program_id) throw new CliError("issue.program_id is required to issue a card."); + } + if (args.action === "card-physical" && !args.shipping_address) { + throw new CliError("issue.shipping_address is required when action=card-physical."); + } + if ((args.action === "card-control" || args.action === "card-get") && !args.card_id) { + throw new CliError("issue.card_id is required when action=card-control | card-get."); + } + if (args.action === "card-control" && !args.control) { + throw new CliError("issue.control (freeze | unfreeze | cancel) is required when action=card-control."); + } +} diff --git a/packages/cli/src/commands/ledger.ts b/packages/cli/src/commands/ledger.ts new file mode 100644 index 0000000..8c2e32f --- /dev/null +++ b/packages/cli/src/commands/ledger.ts @@ -0,0 +1,77 @@ +import { CodeSpar } from "@codespar/sdk"; +import type { LedgerArgs, LedgerResult } from "@codespar/sdk"; +import { CliError } from "../config.js"; +import { info, json, success } from "../output.js"; +import { resolveMetaInput } from "./meta-input.js"; + +interface LedgerCommandOptions { + apiKey: string; + baseUrl: string; + project?: string; + user?: string; + input?: string; + inputFile?: string; + json?: boolean; +} + +const EXAMPLE = + '{"action":"entry","asset":"BRL","source":[{"account":"@external/BRL","amount":12500}],"destination":[{"account":"@wallet/user_1","amount":12500}],"description":"top-up"}'; + +/** + * Wraps `session.ledger(args)` — post a double-entry transaction, read an + * account balance, or create an account against the tenant's self-hosted + * Midaz ledger. The meta-tool router resolves the rail, so no `--server`. + */ +export async function ledgerCommand(opts: LedgerCommandOptions): Promise { + const args = (await resolveMetaInput(opts, "ledger", EXAMPLE)) as unknown as LedgerArgs; + validateLedgerArgs(args); + + const userId = opts.user ?? "cli-user"; + const cs = new CodeSpar({ + apiKey: opts.apiKey, + baseUrl: opts.baseUrl, + projectId: opts.project, + }); + const session = await cs.create(userId, { servers: [] }); + + try { + const result: LedgerResult = await session.ledger(args); + + if (opts.json) { + json(result); + return; + } + + success(`ledger ${args.action} → ${result.status ?? "ok"}`); + if (result.id) info(`Id: ${result.id}`); + if (result.account_id) info(`Account: ${result.account_id}`); + if (result.alias) info(`Alias: ${result.alias}`); + if (result.balances !== undefined) { + info("Balances:"); + process.stdout.write(JSON.stringify(result.balances, null, 2) + "\n"); + } + } finally { + await session.close(); + } +} + +export function validateLedgerArgs(args: LedgerArgs): void { + if (!args.action || !["entry", "balance", "account"].includes(args.action)) { + throw new CliError("ledger.action must be one of: entry, balance, account."); + } + if (args.action === "entry") { + if (!args.asset) throw new CliError("ledger.asset is required when action=entry."); + if (!args.source || args.source.length === 0) { + throw new CliError("ledger.source must be a non-empty array when action=entry."); + } + if (!args.destination || args.destination.length === 0) { + throw new CliError("ledger.destination must be a non-empty array when action=entry."); + } + } + if (args.action === "balance" && !args.account) { + throw new CliError("ledger.account (id) is required when action=balance."); + } + if (args.action === "account" && !args.asset) { + throw new CliError("ledger.asset is required when action=account."); + } +} diff --git a/packages/cli/src/commands/login.ts b/packages/cli/src/commands/login.ts index f13e815..9c36ab8 100644 --- a/packages/cli/src/commands/login.ts +++ b/packages/cli/src/commands/login.ts @@ -9,6 +9,36 @@ interface LoginOptions { baseUrl?: string; } +/** + * Read a secret from the TTY without echoing it. The API key is a + * credential — echoing it leaves the key in terminal scrollback and any + * screen-share. Overrides readline's `_writeToOutput` to show the prompt + * once and swallow the echoed keystrokes (the standard dependency-free + * Node password-prompt technique). On non-TTY input (a piped key) there is + * no echo to suppress, so we read plainly. + */ +async function promptSecret(promptText: string): Promise { + const rl = createInterface({ input, output }); + if (output.isTTY) { + const rlAny = rl as unknown as { _writeToOutput?: (s: string) => void }; + let shownPrompt = false; + rlAny._writeToOutput = (s: string): void => { + void s; + if (!shownPrompt) { + output.write(promptText); + shownPrompt = true; + } + // Everything after the prompt is the echoed secret — swallow it. + }; + } + try { + return await rl.question(promptText); + } finally { + if (output.isTTY) output.write("\n"); + rl.close(); + } +} + /** * Store an API key on disk. If `--api-key` is passed we use it directly; * otherwise we prompt the user interactively. After saving we call @@ -20,12 +50,7 @@ export async function loginCommand(opts: LoginOptions): Promise { if (!apiKey) { info("Get your API key at https://codespar.dev/dashboard/settings?tab=api-keys"); - const rl = createInterface({ input, output }); - try { - apiKey = (await rl.question("API key: ")).trim(); - } finally { - rl.close(); - } + apiKey = (await promptSecret("API key: ")).trim(); } if (!apiKey) throw new CliError("API key is required."); diff --git a/packages/cli/src/commands/logs.ts b/packages/cli/src/commands/logs.ts index ad3eef8..1bb29a0 100644 --- a/packages/cli/src/commands/logs.ts +++ b/packages/cli/src/commands/logs.ts @@ -1,4 +1,4 @@ -import { CliError, type CliConfig } from "../config.js"; +import { CliError } from "../config.js"; import { c, json } from "../output.js"; interface LogEntry { @@ -26,7 +26,7 @@ interface TailOptions { * will close the connection when stdin closes. */ export async function tailLogsCommand( - config: Required>, + config: { apiKey: string; baseUrl: string; project?: string }, opts: TailOptions, ): Promise { const url = new URL("/v1/logs/stream", config.baseUrl); @@ -41,6 +41,7 @@ export async function tailLogsCommand( headers: { Authorization: `Bearer ${config.apiKey}`, Accept: "text/event-stream", + ...(config.project ? { "x-codespar-project": config.project } : {}), }, }); } catch (err) { diff --git a/packages/cli/src/commands/meta-input.ts b/packages/cli/src/commands/meta-input.ts new file mode 100644 index 0000000..ee5ab86 --- /dev/null +++ b/packages/cli/src/commands/meta-input.ts @@ -0,0 +1,37 @@ +import { readFile } from "node:fs/promises"; +import { CliError } from "../config.js"; + +/** + * Resolve a meta-tool command's args from `--input ''` or + * `--input-file `. Shared by the `ledger` / `issue` commands (and + * a good target for charge/ship to migrate onto). Returns a parsed JSON + * object or throws a CliError with an actionable message. + */ +export async function resolveMetaInput( + opts: { input?: string; inputFile?: string }, + name: string, + example: string, +): Promise> { + if (opts.input && opts.inputFile) { + throw new CliError("Pass either --input or --input-file, not both."); + } + if (!opts.input && !opts.inputFile) { + throw new CliError( + `${name} requires --input '' or --input-file . Example: --input '${example}'`, + ); + } + const raw = opts.inputFile + ? await readFile(opts.inputFile, "utf-8") + : (opts.input as string); + const source = opts.inputFile ?? "--input"; + try { + const parsed = JSON.parse(raw) as unknown; + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new CliError(`${source} must be a JSON object.`); + } + return parsed as Record; + } catch (err) { + if (err instanceof CliError) throw err; + throw new CliError(`${source} is not valid JSON: ${(err as Error).message}`); + } +} diff --git a/packages/cli/src/commands/payment-status.ts b/packages/cli/src/commands/payment-status.ts index 0cc5e95..af341fa 100644 --- a/packages/cli/src/commands/payment-status.ts +++ b/packages/cli/src/commands/payment-status.ts @@ -6,6 +6,7 @@ import { info, json, success, warn } from "../output.js"; interface PaymentStatusCommandOptions { apiKey: string; baseUrl: string; + project?: string; user?: string; stream?: boolean; timeout?: string; @@ -35,7 +36,7 @@ export async function paymentStatusCommand( } const userId = opts.user ?? "cli-user"; - const cs = new CodeSpar({ apiKey: opts.apiKey, baseUrl: opts.baseUrl }); + const cs = new CodeSpar({ apiKey: opts.apiKey, baseUrl: opts.baseUrl, projectId: opts.project }); const session = await cs.create(userId, { servers: [] }); try { @@ -102,5 +103,4 @@ function renderResult(result: PaymentStatusResult, asJson: boolean): void { info(`original_status: ${result.original_status}`); info(`idempotency_key: ${result.idempotency_key ?? "-"}`); info(`events: ${result.events.length}`); - process.stdout.write(JSON.stringify(result, null, 2) + "\n"); } diff --git a/packages/cli/src/commands/ship.ts b/packages/cli/src/commands/ship.ts index d8d169c..4379b28 100644 --- a/packages/cli/src/commands/ship.ts +++ b/packages/cli/src/commands/ship.ts @@ -7,6 +7,7 @@ import { info, json, success } from "../output.js"; interface ShipCommandOptions { apiKey: string; baseUrl: string; + project?: string; user?: string; input?: string; inputFile?: string; @@ -23,7 +24,7 @@ export async function shipCommand(opts: ShipCommandOptions): Promise { validateShipArgs(args); const userId = opts.user ?? "cli-user"; - const cs = new CodeSpar({ apiKey: opts.apiKey, baseUrl: opts.baseUrl }); + const cs = new CodeSpar({ apiKey: opts.apiKey, baseUrl: opts.baseUrl, projectId: opts.project }); const session = await cs.create(userId, { servers: [] }); try { @@ -40,7 +41,6 @@ export async function shipCommand(opts: ShipCommandOptions): Promise { if (result.label_url) info(`Label URL: ${result.label_url}`); if (result.estimated_delivery) info(`ETA: ${result.estimated_delivery}`); if (typeof result.cost_minor === "number") info(`Cost (minor units): ${result.cost_minor}`); - process.stdout.write(JSON.stringify(result, null, 2) + "\n"); } finally { await session.close(); } diff --git a/packages/cli/src/commands/verification-status.ts b/packages/cli/src/commands/verification-status.ts index 811a0a6..a09bc89 100644 --- a/packages/cli/src/commands/verification-status.ts +++ b/packages/cli/src/commands/verification-status.ts @@ -6,6 +6,7 @@ import { info, json, success, warn } from "../output.js"; interface VerificationStatusCommandOptions { apiKey: string; baseUrl: string; + project?: string; user?: string; stream?: boolean; timeout?: string; @@ -35,7 +36,7 @@ export async function verificationStatusCommand( } const userId = opts.user ?? "cli-user"; - const cs = new CodeSpar({ apiKey: opts.apiKey, baseUrl: opts.baseUrl }); + const cs = new CodeSpar({ apiKey: opts.apiKey, baseUrl: opts.baseUrl, projectId: opts.project }); const session = await cs.create(userId, { servers: [] }); try { @@ -103,5 +104,4 @@ function renderResult(result: VerificationStatusResult, asJson: boolean): void { info(`idempotency_key: ${result.idempotency_key ?? "-"}`); if (result.hosted_url) info(`hosted_url: ${result.hosted_url}`); info(`events: ${result.events.length}`); - process.stdout.write(JSON.stringify(result, null, 2) + "\n"); } diff --git a/packages/cli/src/commands/wizard.ts b/packages/cli/src/commands/wizard.ts index 73da658..121ac9e 100644 --- a/packages/cli/src/commands/wizard.ts +++ b/packages/cli/src/commands/wizard.ts @@ -6,6 +6,7 @@ import { c, info, json, kv, success, table } from "../output.js"; interface WizardCommandOptions { apiKey: string; baseUrl: string; + project?: string; user?: string; action?: string; country?: string; @@ -39,7 +40,7 @@ export async function wizardCommand( if (opts.returnTo) wizardOpts.return_to = opts.returnTo; const userId = opts.user ?? "cli-user"; - const cs = new CodeSpar({ apiKey: opts.apiKey, baseUrl: opts.baseUrl }); + const cs = new CodeSpar({ apiKey: opts.apiKey, baseUrl: opts.baseUrl, projectId: opts.project }); const session = await cs.create(userId, { servers: [] }); try { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 90350fa..fb9469a 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -24,10 +24,11 @@ import { shipCommand } from "./commands/ship.js"; import { paymentStatusCommand } from "./commands/payment-status.js"; import { verificationStatusCommand } from "./commands/verification-status.js"; import { wizardCommand } from "./commands/wizard.js"; +import { ledgerCommand } from "./commands/ledger.js"; +import { issueCommand } from "./commands/issue.js"; import { c } from "./output.js"; import { printBanner } from "./banner.js"; - -const VERSION = "0.4.0"; +import { VERSION } from "./version.js"; const program = new Command(); program @@ -36,15 +37,28 @@ program .version(VERSION, "-v, --version") .option("--api-key ", "CodeSpar API key (overrides config + env)") .option("--base-url ", "API base URL (overrides config + env)") + .option("--project ", "Project to scope requests to (overrides config + env)") .option("--json", "Output machine-readable JSON instead of tables"); +/** + * Resolve auth + scope from CLI flags > env > config file. The single place + * `project` is wired, so every command — raw ApiClient and SDK alike — + * scopes to the right project (`x-codespar-project`) instead of silently + * falling back to the org default. + */ +async function resolveAuth(): Promise<{ apiKey: string; baseUrl: string; project?: string }> { + const config = await loadConfig(); + const root = program.opts<{ apiKey?: string; baseUrl?: string; project?: string }>(); + return { + apiKey: root.apiKey ?? requireApiKey(config), + baseUrl: root.baseUrl ?? config.baseUrl ?? "https://api.codespar.dev", + project: root.project ?? config.project, + }; +} + /** Build an authenticated ApiClient from config + CLI-level flags. */ async function authedClient(): Promise { - const config = await loadConfig(); - const root = program.opts<{ apiKey?: string; baseUrl?: string }>(); - const apiKey = root.apiKey ?? requireApiKey(config); - const baseUrl = root.baseUrl ?? config.baseUrl ?? "https://api.codespar.dev"; - return new ApiClient({ apiKey, baseUrl }); + return new ApiClient(await resolveAuth()); } function rootJsonFlag(): boolean { @@ -129,14 +143,10 @@ program .option("-f, --input-file ", "Input as JSON file path") .option("-u, --user ", "User id for the session (default: cli-user)") .action(async (tool: string, opts: { server: string; input?: string; inputFile?: string; user?: string }) => { - const config = await loadConfig(); - const root = program.opts<{ apiKey?: string; baseUrl?: string }>(); - const apiKey = root.apiKey ?? requireApiKey(config); - const baseUrl = root.baseUrl ?? config.baseUrl ?? "https://api.codespar.dev"; + const auth = await resolveAuth(); await executeCommand(tool, { ...opts, - apiKey, - baseUrl, + ...auth, json: rootJsonFlag(), }); }); @@ -226,14 +236,10 @@ program query: string, opts: { limit?: string; category?: string; country?: string; user?: string }, ) => { - const config = await loadConfig(); - const root = program.opts<{ apiKey?: string; baseUrl?: string }>(); - const apiKey = root.apiKey ?? requireApiKey(config); - const baseUrl = root.baseUrl ?? config.baseUrl ?? "https://api.codespar.dev"; + const auth = await resolveAuth(); await discoverCommand(query, { ...opts, - apiKey, - baseUrl, + ...auth, json: rootJsonFlag(), }); }, @@ -247,14 +253,10 @@ program .option("-u, --user ", "User id for the session (default: cli-user)") .action( async (opts: { input?: string; inputFile?: string; user?: string }) => { - const config = await loadConfig(); - const root = program.opts<{ apiKey?: string; baseUrl?: string }>(); - const apiKey = root.apiKey ?? requireApiKey(config); - const baseUrl = root.baseUrl ?? config.baseUrl ?? "https://api.codespar.dev"; + const auth = await resolveAuth(); await chargeCommand({ ...opts, - apiKey, - baseUrl, + ...auth, json: rootJsonFlag(), }); }, @@ -268,14 +270,10 @@ program .option("-u, --user ", "User id for the session (default: cli-user)") .action( async (opts: { input?: string; inputFile?: string; user?: string }) => { - const config = await loadConfig(); - const root = program.opts<{ apiKey?: string; baseUrl?: string }>(); - const apiKey = root.apiKey ?? requireApiKey(config); - const baseUrl = root.baseUrl ?? config.baseUrl ?? "https://api.codespar.dev"; + const auth = await resolveAuth(); await shipCommand({ ...opts, - apiKey, - baseUrl, + ...auth, json: rootJsonFlag(), }); }, @@ -292,14 +290,10 @@ program toolCallId: string, opts: { stream?: boolean; timeout?: string; user?: string }, ) => { - const config = await loadConfig(); - const root = program.opts<{ apiKey?: string; baseUrl?: string }>(); - const apiKey = root.apiKey ?? requireApiKey(config); - const baseUrl = root.baseUrl ?? config.baseUrl ?? "https://api.codespar.dev"; + const auth = await resolveAuth(); await paymentStatusCommand(toolCallId, { ...opts, - apiKey, - baseUrl, + ...auth, json: rootJsonFlag(), }); }, @@ -316,14 +310,10 @@ program toolCallId: string, opts: { stream?: boolean; timeout?: string; user?: string }, ) => { - const config = await loadConfig(); - const root = program.opts<{ apiKey?: string; baseUrl?: string }>(); - const apiKey = root.apiKey ?? requireApiKey(config); - const baseUrl = root.baseUrl ?? config.baseUrl ?? "https://api.codespar.dev"; + const auth = await resolveAuth(); await verificationStatusCommand(toolCallId, { ...opts, - apiKey, - baseUrl, + ...auth, json: rootJsonFlag(), }); }, @@ -348,14 +338,44 @@ program user?: string; }, ) => { - const config = await loadConfig(); - const root = program.opts<{ apiKey?: string; baseUrl?: string }>(); - const apiKey = root.apiKey ?? requireApiKey(config); - const baseUrl = root.baseUrl ?? config.baseUrl ?? "https://api.codespar.dev"; + const auth = await resolveAuth(); await wizardCommand(serverId, { ...opts, - apiKey, - baseUrl, + ...auth, + json: rootJsonFlag(), + }); + }, + ); + +program + .command("ledger") + .description("Post journal entries / read balances / create accounts via codespar_ledger (Midaz)") + .option("-i, --input ", "Ledger args as JSON string") + .option("-f, --input-file ", "Ledger args from JSON file") + .option("-u, --user ", "User id for the session (default: cli-user)") + .action( + async (opts: { input?: string; inputFile?: string; user?: string }) => { + const auth = await resolveAuth(); + await ledgerCommand({ + ...opts, + ...auth, + json: rootJsonFlag(), + }); + }, + ); + +program + .command("issue") + .description("Issue / freeze / read agent spend cards via codespar_issue (Pomelo)") + .option("-i, --input ", "Issue args as JSON string") + .option("-f, --input-file ", "Issue args from JSON file") + .option("-u, --user ", "User id for the session (default: cli-user)") + .action( + async (opts: { input?: string; inputFile?: string; user?: string }) => { + const auth = await resolveAuth(); + await issueCommand({ + ...opts, + ...auth, json: rootJsonFlag(), }); }, @@ -372,11 +392,8 @@ logs .option("-t, --tool ", "Filter by tool name") .option("--limit ", "Request up to N backfilled entries before tailing") .action(async (opts: { server?: string; status?: string; tool?: string; limit?: string }) => { - const config = await loadConfig(); - const root = program.opts<{ apiKey?: string; baseUrl?: string }>(); - const apiKey = root.apiKey ?? requireApiKey(config); - const baseUrl = root.baseUrl ?? config.baseUrl ?? "https://api.codespar.dev"; - await tailLogsCommand({ apiKey, baseUrl }, { ...opts, json: rootJsonFlag() }); + const auth = await resolveAuth(); + await tailLogsCommand(auth, { ...opts, json: rootJsonFlag() }); }); // ============ init ============ diff --git a/packages/cli/src/version.ts b/packages/cli/src/version.ts new file mode 100644 index 0000000..0b1d6a8 --- /dev/null +++ b/packages/cli/src/version.ts @@ -0,0 +1,7 @@ +/** + * Single source of truth for the CLI version. Imported by index.ts (the + * --version flag + banner) and api.ts (the User-Agent header) so the two + * can never drift. Keep in sync with package.json on release (prepublishOnly + * could assert equality if drift ever recurs). + */ +export const VERSION = "0.4.0"; diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index cc55b0f..1bd3573 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -15,5 +15,5 @@ "allowSyntheticDefaultImports": true }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] }