Skip to content
Draft
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
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/

13 changes: 11 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -21,4 +21,13 @@ lint-openapi:
npm run lint:openapi

lint-markdown:
npm run lint:markdown
npm run lint:markdown

cli-install:
cd cli && npm install

cli-build:
cd cli && npm run build

cli:
cd cli && npm run dev --
25 changes: 25 additions & 0 deletions cli/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
129 changes: 129 additions & 0 deletions cli/src/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { GridConfig } from "./config";

export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: {
status: number;
code?: string;
message: string;
details?: unknown;
};
}

export interface PaginatedResponse<T> {
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, string | number | boolean | undefined>
): 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<T>(
method: string,
path: string,
options?: {
params?: Record<string, string | number | boolean | undefined>;
body?: unknown;
}
): Promise<ApiResponse<T>> {
const url = this.buildUrl(path, options?.params);

const headers: Record<string, string> = {
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<T>(
path: string,
params?: Record<string, string | number | boolean | undefined>
): Promise<ApiResponse<T>> {
return this.request<T>("GET", path, { params });
}

async post<T>(path: string, body?: unknown): Promise<ApiResponse<T>> {
return this.request<T>("POST", path, { body });
}

async patch<T>(path: string, body?: unknown): Promise<ApiResponse<T>> {
return this.request<T>("PATCH", path, { body });
}

async delete<T>(path: string): Promise<ApiResponse<T>> {
return this.request<T>("DELETE", path);
}
}
76 changes: 76 additions & 0 deletions cli/src/config.ts
Original file line number Diff line number Diff line change
@@ -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<GridConfig> {
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<GridConfig> = {};

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<GridConfig>): void {
const credentialsPath = getCredentialsPath();
let existing: Partial<GridConfig> = {};

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,
});
}
42 changes: 42 additions & 0 deletions cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -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>", "Path to credentials file")
.option(
"-u, --base-url <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);
56 changes: 56 additions & 0 deletions cli/src/output.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { ApiResponse } from "./client";

export interface CliOutput<T = unknown> {
success: boolean;
data?: T;
error?: {
code?: string;
message: string;
details?: unknown;
};
}

export function formatOutput<T>(response: ApiResponse<T>): string {
const output: CliOutput<T> = {
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<T>(data: T): string {
const output: CliOutput<T> = {
success: true,
data,
};
return JSON.stringify(output, null, 2);
}

export function output(result: string): void {
console.log(result);
}

export function outputResponse<T>(response: ApiResponse<T>): void {
output(formatOutput(response));
if (!response.success) {
process.exitCode = 1;
}
}
19 changes: 19 additions & 0 deletions cli/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
Loading