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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"dev": "tsc --watch",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist",
"test": "vitest run",
"prepublishOnly": "npm run build"
},
"keywords": [
Expand Down
74 changes: 74 additions & 0 deletions packages/cli/src/__tests__/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { ApiClient } from "../api.js";

type Init = { headers: Record<string, string>; signal?: AbortSignal };

function mockFetch(impl: (url: URL, init: Init) => Response | Promise<Response>) {
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<string, string> = {};
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<string, string> = {};
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<Response>((_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/);
});
});
95 changes: 95 additions & 0 deletions packages/cli/src/__tests__/meta-tools.test.ts
Original file line number Diff line number Diff line change
@@ -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" },
);
});
});
45 changes: 38 additions & 7 deletions packages/cli/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,29 @@
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
* every endpoint the CLI needs (catalog listings, session bookkeeping),
* so we hit the HTTP surface directly with the user's API key.
*/
export class ApiClient {
constructor(private readonly config: Required<Pick<CliConfig, "apiKey" | "baseUrl">>) {}
private readonly timeoutMs: number;

constructor(private readonly config: ApiClientConfig) {
this.timeoutMs = config.timeoutMs ?? 30_000;
}

async get<T>(path: string, query?: Record<string, string | undefined>): Promise<T> {
return this.request<T>("GET", path, undefined, query);
Expand All @@ -33,21 +50,35 @@ export class ApiClient {
}
}

const headers: Record<string, string> = {
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) {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/charge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,7 +24,7 @@ export async function chargeCommand(opts: ChargeCommandOptions): Promise<void> {
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 {
Expand All @@ -44,7 +45,6 @@ export async function chargeCommand(opts: ChargeCommandOptions): Promise<void> {
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();
}
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ interface ExecuteOptions {
user?: string;
apiKey: string;
baseUrl: string;
project?: string;
json?: boolean;
}

Expand All @@ -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 {
Expand Down
73 changes: 73 additions & 0 deletions packages/cli/src/commands/issue.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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.");
}
}
Loading
Loading