Skip to content

Commit c2dfb64

Browse files
merge: resolve conflicts with main (User-Agent header support)
Co-Authored-By: Chris K <ckorhonen@gmail.com>
2 parents 0e3538a + eb3ef4a commit c2dfb64

10 files changed

Lines changed: 177 additions & 21 deletions

.agents/rules.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,9 +160,9 @@ Key aspects:
160160
### Error Handling
161161

162162
- API errors are wrapped in `OpenSeaAPIError` (includes status code, response body, path).
163-
- CLI catches `OpenSeaAPIError` and outputs structured JSON to stderr, then exits with code 1.
163+
- CLI catches `OpenSeaAPIError` and outputs structured JSON to stderr, then exits with code 1 (or code 3 for rate limiting).
164164
- Authentication errors (missing API key) exit with code 2.
165-
- Exit codes: 0 = success, 1 = API error, 2 = auth error.
165+
- Exit codes: 0 = success, 1 = API error, 2 = auth error, 3 = rate limited (HTTP 429).
166166

167167
## Design Rules
168168

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,8 +167,9 @@ console.log(formatToon(data))
167167
## Exit Codes
168168

169169
- `0` - Success
170-
- `1` - API error
170+
- `1` - API error (non-429)
171171
- `2` - Authentication error
172+
- `3` - Rate limited (HTTP 429)
172173

173174
## Requirements
174175

src/cli.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ import {
1414
import type { OutputFormat } from "./output.js"
1515
import { parseIntOption } from "./parse.js"
1616

17+
const EXIT_API_ERROR = 1
18+
const EXIT_AUTH_ERROR = 2
19+
const EXIT_RATE_LIMITED = 3
20+
1721
const BANNER = `
1822
____ _____
1923
/ __ \\ / ____|
@@ -57,7 +61,7 @@ function getClient(): OpenSeaClient {
5761
console.error(
5862
"Error: API key required. Use --api-key or set OPENSEA_API_KEY environment variable.",
5963
)
60-
process.exit(2)
64+
process.exit(EXIT_AUTH_ERROR)
6165
}
6266

6367
const maxRetries = opts.retry
@@ -96,10 +100,11 @@ async function main() {
96100
await program.parseAsync(process.argv)
97101
} catch (error) {
98102
if (error instanceof OpenSeaAPIError) {
103+
const isRateLimited = error.statusCode === 429
99104
console.error(
100105
JSON.stringify(
101106
{
102-
error: "API Error",
107+
error: isRateLimited ? "Rate Limited" : "API Error",
103108
status: error.statusCode,
104109
path: error.path,
105110
message: error.responseBody,
@@ -108,7 +113,7 @@ async function main() {
108113
2,
109114
),
110115
)
111-
process.exit(1)
116+
process.exit(isRateLimited ? EXIT_RATE_LIMITED : EXIT_API_ERROR)
112117
}
113118
const label =
114119
error instanceof TypeError ? "Network Error" : (error as Error).name
@@ -122,7 +127,7 @@ async function main() {
122127
2,
123128
),
124129
)
125-
process.exit(1)
130+
process.exit(EXIT_API_ERROR)
126131
}
127132
}
128133

src/client.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import type { OpenSeaClientConfig } from "./types/index.js"
22

3+
declare const __VERSION__: string
4+
35
const DEFAULT_BASE_URL = "https://api.opensea.io"
46
const DEFAULT_TIMEOUT_MS = 30_000
57
const DEFAULT_MAX_RETRIES = 0
68
const DEFAULT_RETRY_BASE_DELAY_MS = 1_000
9+
const USER_AGENT = `opensea-cli/${__VERSION__}`
710

811
function isRetryableStatus(status: number, method: string): boolean {
912
if (status === 429) return true
@@ -42,6 +45,14 @@ export class OpenSeaClient {
4245
this.retryBaseDelay = config.retryBaseDelay ?? DEFAULT_RETRY_BASE_DELAY_MS
4346
}
4447

48+
private get defaultHeaders(): Record<string, string> {
49+
return {
50+
Accept: "application/json",
51+
"User-Agent": USER_AGENT,
52+
"x-api-key": this.apiKey,
53+
}
54+
}
55+
4556
async get<T>(path: string, params?: Record<string, unknown>): Promise<T> {
4657
const url = new URL(`${this.baseUrl}${path}`)
4758

@@ -61,10 +72,7 @@ export class OpenSeaClient {
6172
url.toString(),
6273
{
6374
method: "GET",
64-
headers: {
65-
Accept: "application/json",
66-
"x-api-key": this.apiKey,
67-
},
75+
headers: this.defaultHeaders,
6876
},
6977
path,
7078
)
@@ -87,10 +95,7 @@ export class OpenSeaClient {
8795
}
8896
}
8997

90-
const headers: Record<string, string> = {
91-
Accept: "application/json",
92-
"x-api-key": this.apiKey,
93-
}
98+
const headers: Record<string, string> = { ...this.defaultHeaders }
9499

95100
if (body) {
96101
headers["Content-Type"] = "application/json"

test/cli-api-error.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { Command } from "commander"
2+
import { afterAll, expect, it, vi } from "vitest"
3+
import { OpenSeaAPIError } from "../src/client.js"
4+
5+
vi.mock("../src/commands/index.js", () => ({
6+
accountsCommand: () => new Command("accounts"),
7+
collectionsCommand: () => new Command("collections"),
8+
eventsCommand: () => new Command("events"),
9+
listingsCommand: () => new Command("listings"),
10+
nftsCommand: () => new Command("nfts"),
11+
offersCommand: () => new Command("offers"),
12+
searchCommand: () => new Command("search"),
13+
swapsCommand: () => new Command("swaps"),
14+
tokensCommand: () => new Command("tokens"),
15+
}))
16+
17+
const exitSpy = vi
18+
.spyOn(process, "exit")
19+
.mockImplementation(() => undefined as never)
20+
const stderrSpy = vi.spyOn(console, "error").mockImplementation(() => {})
21+
22+
vi.spyOn(Command.prototype, "parseAsync").mockRejectedValue(
23+
new OpenSeaAPIError(404, "Not Found", "/api/v2/missing"),
24+
)
25+
26+
afterAll(() => {
27+
vi.restoreAllMocks()
28+
})
29+
30+
it("exits with code 1 and 'API Error' label on non-429 API error", async () => {
31+
await import("../src/cli.js")
32+
await vi.waitFor(() => {
33+
expect(exitSpy).toHaveBeenCalled()
34+
})
35+
36+
expect(exitSpy).toHaveBeenCalledWith(1)
37+
const output = stderrSpy.mock.calls[0][0] as string
38+
const parsed = JSON.parse(output)
39+
expect(parsed.error).toBe("API Error")
40+
expect(parsed.status).toBe(404)
41+
})

test/cli-network-error.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Command } from "commander"
2+
import { afterAll, expect, it, vi } from "vitest"
3+
4+
vi.mock("../src/commands/index.js", () => ({
5+
accountsCommand: () => new Command("accounts"),
6+
collectionsCommand: () => new Command("collections"),
7+
eventsCommand: () => new Command("events"),
8+
listingsCommand: () => new Command("listings"),
9+
nftsCommand: () => new Command("nfts"),
10+
offersCommand: () => new Command("offers"),
11+
searchCommand: () => new Command("search"),
12+
swapsCommand: () => new Command("swaps"),
13+
tokensCommand: () => new Command("tokens"),
14+
}))
15+
16+
const exitSpy = vi
17+
.spyOn(process, "exit")
18+
.mockImplementation(() => undefined as never)
19+
const stderrSpy = vi.spyOn(console, "error").mockImplementation(() => {})
20+
21+
vi.spyOn(Command.prototype, "parseAsync").mockRejectedValue(
22+
new TypeError("fetch failed"),
23+
)
24+
25+
afterAll(() => {
26+
vi.restoreAllMocks()
27+
})
28+
29+
it("exits with code 1 on non-API errors", async () => {
30+
await import("../src/cli.js")
31+
await vi.waitFor(() => {
32+
expect(exitSpy).toHaveBeenCalled()
33+
})
34+
35+
expect(exitSpy).toHaveBeenCalledWith(1)
36+
const output = stderrSpy.mock.calls[0][0] as string
37+
const parsed = JSON.parse(output)
38+
expect(parsed.error).toBe("Network Error")
39+
})

test/cli-rate-limit.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { Command } from "commander"
2+
import { afterAll, expect, it, vi } from "vitest"
3+
import { OpenSeaAPIError } from "../src/client.js"
4+
5+
vi.mock("../src/commands/index.js", () => ({
6+
accountsCommand: () => new Command("accounts"),
7+
collectionsCommand: () => new Command("collections"),
8+
eventsCommand: () => new Command("events"),
9+
listingsCommand: () => new Command("listings"),
10+
nftsCommand: () => new Command("nfts"),
11+
offersCommand: () => new Command("offers"),
12+
searchCommand: () => new Command("search"),
13+
swapsCommand: () => new Command("swaps"),
14+
tokensCommand: () => new Command("tokens"),
15+
}))
16+
17+
const exitSpy = vi
18+
.spyOn(process, "exit")
19+
.mockImplementation(() => undefined as never)
20+
const stderrSpy = vi.spyOn(console, "error").mockImplementation(() => {})
21+
22+
vi.spyOn(Command.prototype, "parseAsync").mockRejectedValue(
23+
new OpenSeaAPIError(429, "Rate limit exceeded", "/api/v2/test"),
24+
)
25+
26+
afterAll(() => {
27+
vi.restoreAllMocks()
28+
})
29+
30+
it("exits with code 3 and 'Rate Limited' label on 429 error", async () => {
31+
await import("../src/cli.js")
32+
await vi.waitFor(() => {
33+
expect(exitSpy).toHaveBeenCalled()
34+
})
35+
36+
expect(exitSpy).toHaveBeenCalledWith(3)
37+
const output = stderrSpy.mock.calls[0][0] as string
38+
const parsed = JSON.parse(output)
39+
expect(parsed.error).toBe("Rate Limited")
40+
expect(parsed.status).toBe(429)
41+
expect(parsed.path).toBe("/api/v2/test")
42+
expect(parsed.message).toBe("Rate limit exceeded")
43+
})

test/client.test.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,11 @@ describe("OpenSeaClient", () => {
3939
"https://api.opensea.io/api/v2/test",
4040
expect.objectContaining({
4141
method: "GET",
42-
headers: {
42+
headers: expect.objectContaining({
4343
Accept: "application/json",
44+
"User-Agent": expect.stringMatching(/^opensea-cli\/\d+\.\d+\.\d+$/),
4445
"x-api-key": "test-key",
45-
},
46+
}),
4647
}),
4748
)
4849
expect(result).toEqual(mockResponse)
@@ -93,10 +94,11 @@ describe("OpenSeaClient", () => {
9394
"https://api.opensea.io/api/v2/refresh",
9495
expect.objectContaining({
9596
method: "POST",
96-
headers: {
97+
headers: expect.objectContaining({
9798
Accept: "application/json",
99+
"User-Agent": expect.stringMatching(/^opensea-cli\/\d+\.\d+\.\d+$/),
98100
"x-api-key": "test-key",
99-
},
101+
}),
100102
}),
101103
)
102104
expect(result).toEqual(mockResponse)
@@ -111,11 +113,12 @@ describe("OpenSeaClient", () => {
111113
"https://api.opensea.io/api/v2/create",
112114
expect.objectContaining({
113115
method: "POST",
114-
headers: {
116+
headers: expect.objectContaining({
115117
Accept: "application/json",
116118
"Content-Type": "application/json",
119+
"User-Agent": expect.stringMatching(/^opensea-cli\/\d+\.\d+\.\d+$/),
117120
"x-api-key": "test-key",
118-
},
121+
}),
119122
body: JSON.stringify({ name: "test" }),
120123
}),
121124
)

tsup.config.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
1+
import { readFileSync } from "node:fs"
12
import { defineConfig } from "tsup"
23

4+
const pkg = JSON.parse(readFileSync("./package.json", "utf-8")) as {
5+
version: string
6+
}
7+
38
export default defineConfig([
49
{
510
entry: { cli: "src/cli.ts" },
611
format: ["esm"],
712
clean: true,
813
sourcemap: true,
914
target: "node18",
15+
define: {
16+
__VERSION__: JSON.stringify(pkg.version),
17+
},
1018
banner: {
1119
js: "#!/usr/bin/env node",
1220
},
@@ -17,5 +25,8 @@ export default defineConfig([
1725
dts: true,
1826
sourcemap: true,
1927
target: "node18",
28+
define: {
29+
__VERSION__: JSON.stringify(pkg.version),
30+
},
2031
},
2132
])

vitest.config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
1+
import { readFileSync } from "node:fs"
12
import { defineConfig } from "vitest/config"
23

4+
const pkg = JSON.parse(readFileSync("./package.json", "utf-8")) as {
5+
version: string
6+
}
7+
38
export default defineConfig({
9+
define: {
10+
__VERSION__: JSON.stringify(pkg.version),
11+
},
412
test: {
513
coverage: {
614
provider: "v8",

0 commit comments

Comments
 (0)