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
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 --
28 changes: 28 additions & 0 deletions cli/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"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"
},
"engines": {
"node": ">=18.0.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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new URL(path, this.config.baseUrl) will drop the last path segment of baseUrl when baseUrl doesn’t end with / and path is relative (e.g., baseUrl https://api.lightspark.com/grid/2025-10-13 + path customers becomes https://api.lightspark.com/grid/customers). This will cause requests to hit the wrong endpoint unless callers always pass an absolute /... path or baseUrl always includes a trailing slash.

Also affects request() calls via buildUrl().

Consider normalizing baseUrl to include a trailing / (or always prefix path with /) before constructing the URL.

Context Used: Context from dashboard - CLAUDE.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/src/client.ts
Line: 37:37

Comment:
`new URL(path, this.config.baseUrl)` will drop the last path segment of `baseUrl` when `baseUrl` doesn’t end with `/` and `path` is relative (e.g., baseUrl `https://api.lightspark.com/grid/2025-10-13` + path `customers` becomes `https://api.lightspark.com/grid/customers`). This will cause requests to hit the wrong endpoint unless callers always pass an absolute `/...` path or `baseUrl` always includes a trailing slash.

Also affects `request()` calls via `buildUrl()`.

Consider normalizing `baseUrl` to include a trailing `/` (or always prefix `path` with `/`) before constructing the URL.

**Context Used:** Context from `dashboard` - CLAUDE.md ([source](https://app.greptile.com/review/custom-context?memory=21abe025-35ab-4ae8-a4a1-0071c2ac3b98))

How can I resolve this? If you propose a fix, please make it concise.

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);
}
}
88 changes: 88 additions & 0 deletions cli/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
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");
try {
return JSON.parse(content);
} catch {
throw new Error(`Invalid JSON in credentials file: ${credentialsPath}`);
}
}
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");
try {
fileConfig = JSON.parse(content);
} catch {
throw new Error(`Invalid JSON in config file: ${options.configPath}`);
}
} 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");
try {
existing = JSON.parse(content);
} catch {
existing = {};
}
}

const merged = { ...existing, ...config };
fs.writeFileSync(credentialsPath, JSON.stringify(merged, null, 2), {
mode: 0o600,
});
Comment on lines +85 to +87
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fs.writeFileSync(..., { mode: 0o600 }) only reliably sets permissions on file creation; if the file already exists with broader permissions, Node won’t necessarily tighten them. This can leave credentials readable by other users depending on the prior mode.

If you want to guarantee 0600, you likely need to chmod after writing when the file already exists.

Context Used: Context from dashboard - CLAUDE.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/src/config.ts
Line: 85:87

Comment:
`fs.writeFileSync(..., { mode: 0o600 })` only reliably sets permissions on file creation; if the file already exists with broader permissions, Node won’t necessarily tighten them. This can leave credentials readable by other users depending on the prior mode.

If you want to guarantee `0600`, you likely need to `chmod` after writing when the file already exists.

**Context Used:** Context from `dashboard` - CLAUDE.md ([source](https://app.greptile.com/review/custom-context?memory=21abe025-35ab-4ae8-a4a1-0071c2ac3b98))

How can I resolve this? If you propose a fix, please make it concise.

}
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);
Comment on lines +40 to +42
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

program.parse(process.argv) runs on import, which makes this module hard to use as a library (and can surprise tests/other code that imports program or GridClient). This is especially relevant since you also export { program, getClient, ... }.

If you intend index.ts to be both the CLI entrypoint and an importable module, consider moving program.parse(...) behind a require.main === module / import.meta guard or into a separate bin entry file.

Context Used: Context from dashboard - CLAUDE.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/src/index.ts
Line: 40:42

Comment:
`program.parse(process.argv)` runs on import, which makes this module hard to use as a library (and can surprise tests/other code that imports `program` or `GridClient`). This is especially relevant since you also `export { program, getClient, ... }`.

If you intend `index.ts` to be both the CLI entrypoint and an importable module, consider moving `program.parse(...)` behind a `require.main === module` / `import.meta` guard or into a separate `bin` entry file.

**Context Used:** Context from `dashboard` - CLAUDE.md ([source](https://app.greptile.com/review/custom-context?memory=21abe025-35ab-4ae8-a4a1-0071c2ac3b98))

How can I resolve this? If you propose a fix, please make it concise.

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;
}
}
Loading