-
Notifications
You must be signed in to change notification settings - Fork 3
Add Grid CLI core infrastructure #134
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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" | ||
| } | ||
| 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Also affects Consider normalizing Context Used: Context from Prompt To Fix With AIThis 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); | ||
| } | ||
| } | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If you want to guarantee Context Used: Context from Prompt To Fix With AIThis 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. |
||
| } | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If you intend Context Used: Context from Prompt To Fix With AIThis 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. |
||
| 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; | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.