diff --git a/.gitignore b/.gitignore index 83d32db..777b53b 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,13 @@ bin/ .env local.properties +# Grid CLI credentials +.grid-credentials +.claude/settings.local.json + +# CLI build output +cli/dist/ + # Figma design tokens (local reference only) mintlify/tokens/ diff --git a/Makefile b/Makefile index 02b6fe1..053a41a 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install build build-openapi mint lint lint-openapi lint-markdown +.PHONY: install build build-openapi mint lint lint-openapi lint-markdown cli-install cli-build cli install: npm install @@ -21,4 +21,13 @@ lint-openapi: npm run lint:openapi lint-markdown: - npm run lint:markdown \ No newline at end of file + npm run lint:markdown + +cli-install: + cd cli && npm install + +cli-build: + cd cli && npm run build + +cli: + cd cli && npm run dev -- \ No newline at end of file diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000..49b0286 --- /dev/null +++ b/cli/package.json @@ -0,0 +1,25 @@ +{ + "name": "grid-cli", + "version": "1.0.0", + "description": "CLI tool for Grid API", + "main": "dist/index.js", + "bin": { + "grid": "./dist/index.js" + }, + "scripts": { + "build": "tsc", + "dev": "ts-node src/index.ts", + "start": "node dist/index.js", + "clean": "rm -rf dist" + }, + "dependencies": { + "commander": "^12.1.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "ts-node": "^10.9.2", + "typescript": "^5.4.0" + }, + "author": "Lightspark", + "license": "Apache-2.0" +} diff --git a/cli/src/client.ts b/cli/src/client.ts new file mode 100644 index 0000000..30acc86 --- /dev/null +++ b/cli/src/client.ts @@ -0,0 +1,129 @@ +import { GridConfig } from "./config"; + +export interface ApiResponse { + success: boolean; + data?: T; + error?: { + status: number; + code?: string; + message: string; + details?: unknown; + }; +} + +export interface PaginatedResponse { + data: T[]; + hasMore: boolean; + nextCursor?: string; + totalCount?: number; +} + +export class GridClient { + private config: GridConfig; + + constructor(config: GridConfig) { + this.config = config; + } + + private getAuthHeader(): string { + const credentials = `${this.config.apiTokenId}:${this.config.apiClientSecret}`; + return `Basic ${Buffer.from(credentials).toString("base64")}`; + } + + private buildUrl( + path: string, + params?: Record + ): string { + const url = new URL(path, this.config.baseUrl); + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined) { + url.searchParams.append(key, String(value)); + } + }); + } + return url.toString(); + } + + async request( + method: string, + path: string, + options?: { + params?: Record; + body?: unknown; + } + ): Promise> { + const url = this.buildUrl(path, options?.params); + + const headers: Record = { + Authorization: this.getAuthHeader(), + Accept: "application/json", + }; + + const fetchOptions: RequestInit = { + method, + headers, + }; + + if (options?.body) { + headers["Content-Type"] = "application/json"; + fetchOptions.body = JSON.stringify(options.body); + } + + try { + const response = await fetch(url, fetchOptions); + const contentType = response.headers.get("content-type"); + let data: unknown = null; + + if (contentType?.includes("application/json")) { + data = await response.json(); + } else { + data = await response.text(); + } + + if (!response.ok) { + const errorData = data as { code?: string; message?: string }; + return { + success: false, + error: { + status: response.status, + code: errorData?.code, + message: + errorData?.message || response.statusText || "Request failed", + details: data, + }, + }; + } + + return { success: true, data: data as T }; + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return { + success: false, + error: { + status: 0, + message: `Network error: ${message}`, + }, + }; + } + } + + async get( + path: string, + params?: Record + ): Promise> { + return this.request("GET", path, { params }); + } + + async post(path: string, body?: unknown): Promise> { + return this.request("POST", path, { body }); + } + + async patch(path: string, body?: unknown): Promise> { + return this.request("PATCH", path, { body }); + } + + async delete(path: string): Promise> { + return this.request("DELETE", path); + } +} diff --git a/cli/src/config.ts b/cli/src/config.ts new file mode 100644 index 0000000..2fc464b --- /dev/null +++ b/cli/src/config.ts @@ -0,0 +1,76 @@ +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; + +export interface GridConfig { + apiTokenId: string; + apiClientSecret: string; + baseUrl: string; +} + +const DEFAULT_BASE_URL = "https://api.lightspark.com/grid/2025-10-13"; +const CREDENTIALS_FILE = ".grid-credentials"; + +function getCredentialsPath(): string { + return path.join(os.homedir(), CREDENTIALS_FILE); +} + +function loadCredentialsFile(): Partial { + const credentialsPath = getCredentialsPath(); + if (fs.existsSync(credentialsPath)) { + const content = fs.readFileSync(credentialsPath, "utf-8"); + return JSON.parse(content); + } + return {}; +} + +export function loadConfig(options: { + configPath?: string; + baseUrl?: string; +}): GridConfig { + let fileConfig: Partial = {}; + + if (options.configPath) { + if (!fs.existsSync(options.configPath)) { + throw new Error(`Config file not found: ${options.configPath}`); + } + const content = fs.readFileSync(options.configPath, "utf-8"); + fileConfig = JSON.parse(content); + } else { + fileConfig = loadCredentialsFile(); + } + + const apiTokenId = + process.env.GRID_API_TOKEN_ID || fileConfig.apiTokenId || ""; + const apiClientSecret = + process.env.GRID_API_CLIENT_SECRET || fileConfig.apiClientSecret || ""; + const baseUrl = + options.baseUrl || + process.env.GRID_BASE_URL || + fileConfig.baseUrl || + DEFAULT_BASE_URL; + + if (!apiTokenId || !apiClientSecret) { + throw new Error( + `Missing credentials. Set GRID_API_TOKEN_ID and GRID_API_CLIENT_SECRET environment variables, ` + + `or create ${getCredentialsPath()} with apiTokenId and apiClientSecret fields.` + ); + } + + return { apiTokenId, apiClientSecret, baseUrl }; +} + +export function saveCredentials(config: Partial): void { + const credentialsPath = getCredentialsPath(); + let existing: Partial = {}; + + if (fs.existsSync(credentialsPath)) { + const content = fs.readFileSync(credentialsPath, "utf-8"); + existing = JSON.parse(content); + } + + const merged = { ...existing, ...config }; + fs.writeFileSync(credentialsPath, JSON.stringify(merged, null, 2), { + mode: 0o600, + }); +} diff --git a/cli/src/index.ts b/cli/src/index.ts new file mode 100644 index 0000000..860c550 --- /dev/null +++ b/cli/src/index.ts @@ -0,0 +1,42 @@ +#!/usr/bin/env node + +import { Command } from "commander"; +import { loadConfig, GridConfig } from "./config"; +import { GridClient } from "./client"; +import { formatError, output } from "./output"; + +export interface GlobalOptions { + config?: string; + baseUrl?: string; +} + +const program = new Command(); + +program + .name("grid") + .description("CLI for Grid API - manage global payments") + .version("1.0.0") + .option("-c, --config ", "Path to credentials file") + .option( + "-u, --base-url ", + "Base URL for API (default: https://api.lightspark.com/grid/2025-10-13)" + ); + +function getClient(options: GlobalOptions): GridClient | null { + try { + const config = loadConfig({ + configPath: options.config, + baseUrl: options.baseUrl, + }); + return new GridClient(config); + } catch (err) { + const message = err instanceof Error ? err.message : "Configuration error"; + output(formatError(message)); + process.exitCode = 1; + return null; + } +} + +export { program, getClient, GridClient, GridConfig }; + +program.parse(process.argv); diff --git a/cli/src/output.ts b/cli/src/output.ts new file mode 100644 index 0000000..dfd8060 --- /dev/null +++ b/cli/src/output.ts @@ -0,0 +1,56 @@ +import { ApiResponse } from "./client"; + +export interface CliOutput { + success: boolean; + data?: T; + error?: { + code?: string; + message: string; + details?: unknown; + }; +} + +export function formatOutput(response: ApiResponse): string { + const output: CliOutput = { + success: response.success, + }; + + if (response.success) { + output.data = response.data; + } else if (response.error) { + output.error = { + code: response.error.code, + message: response.error.message, + details: response.error.details, + }; + } + + return JSON.stringify(output, null, 2); +} + +export function formatError(message: string, details?: unknown): string { + const output: CliOutput = { + success: false, + error: { message, details }, + }; + return JSON.stringify(output, null, 2); +} + +export function formatSuccess(data: T): string { + const output: CliOutput = { + success: true, + data, + }; + return JSON.stringify(output, null, 2); +} + +export function output(result: string): void { + console.log(result); +} + +export function outputResponse(response: ApiResponse): void { + output(formatOutput(response)); + if (!response.success) { + process.exitCode = 1; + } +} diff --git a/cli/tsconfig.json b/cli/tsconfig.json new file mode 100644 index 0000000..1951778 --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/package.json b/package.json index cfcfe50..c62ee9c 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,10 @@ "lint": "npm run lint:openapi", "broken-links": "cd mintlify && mint broken-links", "validate": "npm run lint", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "cli:install": "cd cli && npm install", + "cli:build": "cd cli && npm run build", + "cli:dev": "cd cli && npm run dev" }, "dependencies": { "@redocly/cli": "^1.34.5",