Skip to content

Commit 3e54b83

Browse files
merge: resolve conflicts with main (health command, fields/max-lines, API key prefix)
Co-Authored-By: Chris K <ckorhonen@gmail.com>
2 parents c2dfb64 + f3d2c7a commit 3e54b83

17 files changed

Lines changed: 611 additions & 10 deletions

src/cli.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@ import {
44
accountsCommand,
55
collectionsCommand,
66
eventsCommand,
7+
healthCommand,
78
listingsCommand,
89
nftsCommand,
910
offersCommand,
1011
searchCommand,
1112
swapsCommand,
1213
tokensCommand,
1314
} from "./commands/index.js"
14-
import type { OutputFormat } from "./output.js"
15+
import { type OutputFormat, setOutputOptions } from "./output.js"
1516
import { parseIntOption } from "./parse.js"
1617

1718
const EXIT_API_ERROR = 1
@@ -44,6 +45,11 @@ program
4445
.option("--verbose", "Log request and response info to stderr")
4546
.option("--max-retries <n>", "Max retries on 429/5xx (0 to disable)", "3")
4647
.option("--no-retry", "Disable request retries")
48+
.option(
49+
"--fields <fields>",
50+
"Comma-separated list of fields to include in output",
51+
)
52+
.option("--max-lines <lines>", "Truncate output after N lines")
4753

4854
function getClient(): OpenSeaClient {
4955
const opts = program.opts<{
@@ -85,6 +91,25 @@ function getFormat(): OutputFormat {
8591
return "json"
8692
}
8793

94+
program.hook("preAction", () => {
95+
const opts = program.opts<{
96+
fields?: string
97+
maxLines?: string
98+
}>()
99+
let maxLines: number | undefined
100+
if (opts.maxLines) {
101+
maxLines = parseIntOption(opts.maxLines, "--max-lines")
102+
if (maxLines < 1) {
103+
console.error("Error: --max-lines must be >= 1")
104+
process.exit(2)
105+
}
106+
}
107+
setOutputOptions({
108+
fields: opts.fields?.split(",").map(f => f.trim()),
109+
maxLines,
110+
})
111+
})
112+
88113
program.addCommand(collectionsCommand(getClient, getFormat))
89114
program.addCommand(nftsCommand(getClient, getFormat))
90115
program.addCommand(listingsCommand(getClient, getFormat))
@@ -94,6 +119,7 @@ program.addCommand(accountsCommand(getClient, getFormat))
94119
program.addCommand(tokensCommand(getClient, getFormat))
95120
program.addCommand(searchCommand(getClient, getFormat))
96121
program.addCommand(swapsCommand(getClient, getFormat))
122+
program.addCommand(healthCommand(getClient, getFormat))
97123

98124
async function main() {
99125
try {

src/client.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,11 @@ export class OpenSeaClient {
122122
return this.defaultChain
123123
}
124124

125+
getApiKeyPrefix(): string {
126+
if (this.apiKey.length < 8) return "***"
127+
return `${this.apiKey.slice(0, 4)}...`
128+
}
129+
125130
private async fetchWithRetry(
126131
url: string,
127132
init: RequestInit,

src/commands/health.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Command } from "commander"
2+
import type { OpenSeaClient } from "../client.js"
3+
import { checkHealth } from "../health.js"
4+
import type { OutputFormat } from "../output.js"
5+
import { formatOutput } from "../output.js"
6+
7+
export function healthCommand(
8+
getClient: () => OpenSeaClient,
9+
getFormat: () => OutputFormat,
10+
): Command {
11+
const cmd = new Command("health")
12+
.description("Check API connectivity and authentication")
13+
.action(async () => {
14+
const client = getClient()
15+
const result = await checkHealth(client)
16+
console.log(formatOutput(result, getFormat()))
17+
if (result.status === "error") {
18+
process.exit(result.rate_limited ? 3 : 1)
19+
}
20+
})
21+
22+
return cmd
23+
}

src/commands/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export { accountsCommand } from "./accounts.js"
22
export { collectionsCommand } from "./collections.js"
33
export { eventsCommand } from "./events.js"
4+
export { healthCommand } from "./health.js"
45
export { listingsCommand } from "./listings.js"
56
export { nftsCommand } from "./nfts.js"
67
export { offersCommand } from "./offers.js"

src/health.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { OpenSeaAPIError, type OpenSeaClient } from "./client.js"
2+
import type { HealthResult } from "./types/index.js"
3+
4+
export async function checkHealth(
5+
client: OpenSeaClient,
6+
): Promise<HealthResult> {
7+
const keyPrefix = client.getApiKeyPrefix()
8+
9+
// Step 1: Check basic connectivity with a public endpoint
10+
try {
11+
await client.get("/api/v2/collections", { limit: 1 })
12+
} catch (error) {
13+
let message: string
14+
if (error instanceof OpenSeaAPIError) {
15+
message =
16+
error.statusCode === 429
17+
? "Rate limited: too many requests"
18+
: `API error (${error.statusCode}): ${error.responseBody}`
19+
} else {
20+
message = `Network error: ${(error as Error).message}`
21+
}
22+
return {
23+
status: "error",
24+
key_prefix: keyPrefix,
25+
authenticated: false,
26+
rate_limited:
27+
error instanceof OpenSeaAPIError && error.statusCode === 429,
28+
message,
29+
}
30+
}
31+
32+
// Step 2: Validate authentication with an endpoint that requires a valid API key
33+
try {
34+
await client.get("/api/v2/listings/collection/boredapeyachtclub/all", {
35+
limit: 1,
36+
})
37+
return {
38+
status: "ok",
39+
key_prefix: keyPrefix,
40+
authenticated: true,
41+
rate_limited: false,
42+
message: "Connectivity and authentication are working",
43+
}
44+
} catch (error) {
45+
if (error instanceof OpenSeaAPIError) {
46+
if (error.statusCode === 429) {
47+
return {
48+
status: "error",
49+
key_prefix: keyPrefix,
50+
authenticated: false,
51+
rate_limited: true,
52+
message: "Rate limited: too many requests",
53+
}
54+
}
55+
if (error.statusCode === 401 || error.statusCode === 403) {
56+
return {
57+
status: "error",
58+
key_prefix: keyPrefix,
59+
authenticated: false,
60+
rate_limited: false,
61+
message: `Authentication failed (${error.statusCode}): invalid API key`,
62+
}
63+
}
64+
}
65+
// Non-auth error on listings endpoint — connectivity works but auth is unverified
66+
return {
67+
status: "ok",
68+
key_prefix: keyPrefix,
69+
authenticated: false,
70+
rate_limited: false,
71+
message:
72+
"Connectivity is working but authentication could not be verified",
73+
}
74+
}
75+
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { OpenSeaAPIError, OpenSeaClient } from "./client.js"
2+
export { checkHealth } from "./health.js"
23
export type { OutputFormat } from "./output.js"
34
export { formatOutput } from "./output.js"
45
export { OpenSeaCLI } from "./sdk.js"

src/output.ts

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,36 @@ import { formatToon } from "./toon.js"
22

33
export type OutputFormat = "json" | "table" | "toon"
44

5+
export interface OutputOptions {
6+
fields?: string[]
7+
maxLines?: number
8+
}
9+
10+
let _outputOptions: OutputOptions = {}
11+
12+
export function setOutputOptions(options: OutputOptions): void {
13+
_outputOptions = options
14+
}
15+
516
export function formatOutput(data: unknown, format: OutputFormat): string {
17+
const processed = _outputOptions.fields
18+
? filterFields(data, _outputOptions.fields)
19+
: data
20+
21+
let result: string
622
if (format === "table") {
7-
return formatTable(data)
23+
result = formatTable(processed)
24+
} else if (format === "toon") {
25+
result = formatToon(processed)
26+
} else {
27+
result = JSON.stringify(processed, null, 2)
828
}
9-
if (format === "toon") {
10-
return formatToon(data)
29+
30+
if (_outputOptions.maxLines != null) {
31+
result = truncateOutput(result, _outputOptions.maxLines)
1132
}
12-
return JSON.stringify(data, null, 2)
33+
34+
return result
1335
}
1436

1537
function formatTable(data: unknown): string {
@@ -57,3 +79,36 @@ function formatTable(data: unknown): string {
5779

5880
return String(data)
5981
}
82+
83+
function pickFields(
84+
obj: Record<string, unknown>,
85+
fields: string[],
86+
): Record<string, unknown> {
87+
const result: Record<string, unknown> = {}
88+
for (const field of fields) {
89+
if (field in obj) {
90+
result[field] = obj[field]
91+
}
92+
}
93+
return result
94+
}
95+
96+
function filterFields(data: unknown, fields: string[]): unknown {
97+
if (Array.isArray(data)) {
98+
return data.map(item => filterFields(item, fields))
99+
}
100+
if (data && typeof data === "object") {
101+
return pickFields(data as Record<string, unknown>, fields)
102+
}
103+
return data
104+
}
105+
106+
function truncateOutput(text: string, maxLines: number): string {
107+
const lines = text.split("\n")
108+
if (lines.length <= maxLines) return text
109+
const omitted = lines.length - maxLines
110+
return (
111+
lines.slice(0, maxLines).join("\n") +
112+
`\n... (${omitted} more line${omitted === 1 ? "" : "s"})`
113+
)
114+
}

src/sdk.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { OpenSeaClient } from "./client.js"
2+
import { checkHealth } from "./health.js"
23
import type {
34
Account,
45
AssetEvent,
@@ -9,6 +10,7 @@ import type {
910
Contract,
1011
EventType,
1112
GetTraitsResponse,
13+
HealthResult,
1214
Listing,
1315
NFT,
1416
Offer,
@@ -32,6 +34,7 @@ export class OpenSeaCLI {
3234
readonly tokens: TokensAPI
3335
readonly search: SearchAPI
3436
readonly swaps: SwapsAPI
37+
readonly health: HealthAPI
3538

3639
constructor(config: OpenSeaClientConfig) {
3740
this.client = new OpenSeaClient(config)
@@ -44,6 +47,7 @@ export class OpenSeaCLI {
4447
this.tokens = new TokensAPI(this.client)
4548
this.search = new SearchAPI(this.client)
4649
this.swaps = new SwapsAPI(this.client)
50+
this.health = new HealthAPI(this.client)
4751
}
4852
}
4953

@@ -384,3 +388,11 @@ class SwapsAPI {
384388
})
385389
}
386390
}
391+
392+
class HealthAPI {
393+
constructor(private client: OpenSeaClient) {}
394+
395+
async check(): Promise<HealthResult> {
396+
return checkHealth(this.client)
397+
}
398+
}

src/types/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,11 @@ export interface CommandOptions {
1616
format?: "json" | "table"
1717
raw?: boolean
1818
}
19+
20+
export interface HealthResult {
21+
status: "ok" | "error"
22+
key_prefix: string
23+
authenticated: boolean
24+
rate_limited: boolean
25+
message: string
26+
}

test/cli-api-error.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ vi.mock("../src/commands/index.js", () => ({
1212
searchCommand: () => new Command("search"),
1313
swapsCommand: () => new Command("swaps"),
1414
tokensCommand: () => new Command("tokens"),
15+
healthCommand: () => new Command("health"),
1516
}))
1617

1718
const exitSpy = vi

0 commit comments

Comments
 (0)