@opensea/cli 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -167,8 +167,9 @@ console.log(formatToon(data))
167
167
  ## Exit Codes
168
168
 
169
169
  - `0` - Success
170
- - `1` - API error
170
+ - `1` - API error (non-429)
171
171
  - `2` - Authentication error
172
+ - `3` - Rate limited (HTTP 429)
172
173
 
173
174
  ## Requirements
174
175
 
package/dist/cli.js CHANGED
@@ -1,23 +1,52 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { Command as Command10 } from "commander";
4
+ import { Command as Command11 } from "commander";
5
5
 
6
6
  // src/client.ts
7
7
  var DEFAULT_BASE_URL = "https://api.opensea.io";
8
8
  var DEFAULT_TIMEOUT_MS = 3e4;
9
+ var USER_AGENT = `opensea-cli/${"0.4.2"}`;
10
+ var DEFAULT_MAX_RETRIES = 0;
11
+ var DEFAULT_RETRY_BASE_DELAY_MS = 1e3;
12
+ function isRetryableStatus(status, method) {
13
+ if (status === 429) return true;
14
+ return status >= 500 && method === "GET";
15
+ }
16
+ function parseRetryAfter(header) {
17
+ if (!header) return void 0;
18
+ const seconds = Number(header);
19
+ if (!Number.isNaN(seconds)) return seconds * 1e3;
20
+ const date = Date.parse(header);
21
+ if (!Number.isNaN(date)) return Math.max(0, date - Date.now());
22
+ return void 0;
23
+ }
24
+ function sleep(ms) {
25
+ return new Promise((resolve) => setTimeout(resolve, ms));
26
+ }
9
27
  var OpenSeaClient = class {
10
28
  apiKey;
11
29
  baseUrl;
12
30
  defaultChain;
13
31
  timeoutMs;
14
32
  verbose;
33
+ maxRetries;
34
+ retryBaseDelay;
15
35
  constructor(config) {
16
36
  this.apiKey = config.apiKey;
17
37
  this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
18
38
  this.defaultChain = config.chain ?? "ethereum";
19
39
  this.timeoutMs = config.timeout ?? DEFAULT_TIMEOUT_MS;
20
40
  this.verbose = config.verbose ?? false;
41
+ this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
42
+ this.retryBaseDelay = config.retryBaseDelay ?? DEFAULT_RETRY_BASE_DELAY_MS;
43
+ }
44
+ get defaultHeaders() {
45
+ return {
46
+ Accept: "application/json",
47
+ "User-Agent": USER_AGENT,
48
+ "x-api-key": this.apiKey
49
+ };
21
50
  }
22
51
  async get(path, params) {
23
52
  const url = new URL(`${this.baseUrl}${path}`);
@@ -31,21 +60,14 @@ var OpenSeaClient = class {
31
60
  if (this.verbose) {
32
61
  console.error(`[verbose] GET ${url.toString()}`);
33
62
  }
34
- const response = await fetch(url.toString(), {
35
- method: "GET",
36
- headers: {
37
- Accept: "application/json",
38
- "x-api-key": this.apiKey
63
+ const response = await this.fetchWithRetry(
64
+ url.toString(),
65
+ {
66
+ method: "GET",
67
+ headers: this.defaultHeaders
39
68
  },
40
- signal: AbortSignal.timeout(this.timeoutMs)
41
- });
42
- if (this.verbose) {
43
- console.error(`[verbose] ${response.status} ${response.statusText}`);
44
- }
45
- if (!response.ok) {
46
- const body = await response.text();
47
- throw new OpenSeaAPIError(response.status, body, path);
48
- }
69
+ path
70
+ );
49
71
  return response.json();
50
72
  }
51
73
  async post(path, body, params) {
@@ -57,34 +79,67 @@ var OpenSeaClient = class {
57
79
  }
58
80
  }
59
81
  }
60
- const headers = {
61
- Accept: "application/json",
62
- "x-api-key": this.apiKey
63
- };
82
+ const headers = { ...this.defaultHeaders };
64
83
  if (body) {
65
84
  headers["Content-Type"] = "application/json";
66
85
  }
67
86
  if (this.verbose) {
68
87
  console.error(`[verbose] POST ${url.toString()}`);
69
88
  }
70
- const response = await fetch(url.toString(), {
71
- method: "POST",
72
- headers,
73
- body: body ? JSON.stringify(body) : void 0,
74
- signal: AbortSignal.timeout(this.timeoutMs)
75
- });
76
- if (this.verbose) {
77
- console.error(`[verbose] ${response.status} ${response.statusText}`);
78
- }
79
- if (!response.ok) {
80
- const text = await response.text();
81
- throw new OpenSeaAPIError(response.status, text, path);
82
- }
89
+ const response = await this.fetchWithRetry(
90
+ url.toString(),
91
+ {
92
+ method: "POST",
93
+ headers,
94
+ body: body ? JSON.stringify(body) : void 0
95
+ },
96
+ path
97
+ );
83
98
  return response.json();
84
99
  }
85
100
  getDefaultChain() {
86
101
  return this.defaultChain;
87
102
  }
103
+ getApiKeyPrefix() {
104
+ if (this.apiKey.length < 8) return "***";
105
+ return `${this.apiKey.slice(0, 4)}...`;
106
+ }
107
+ async fetchWithRetry(url, init, path) {
108
+ for (let attempt = 0; ; attempt++) {
109
+ const response = await fetch(url, {
110
+ ...init,
111
+ signal: AbortSignal.timeout(this.timeoutMs)
112
+ });
113
+ if (this.verbose) {
114
+ console.error(`[verbose] ${response.status} ${response.statusText}`);
115
+ }
116
+ if (response.ok) {
117
+ return response;
118
+ }
119
+ const method = init.method ?? "GET";
120
+ if (attempt < this.maxRetries && isRetryableStatus(response.status, method)) {
121
+ const retryAfterMs = parseRetryAfter(
122
+ response.headers.get("Retry-After")
123
+ );
124
+ const backoffMs = this.retryBaseDelay * 2 ** attempt;
125
+ const jitterMs = Math.random() * this.retryBaseDelay;
126
+ const delayMs = Math.max(retryAfterMs ?? 0, backoffMs) + jitterMs;
127
+ if (this.verbose) {
128
+ console.error(
129
+ `[verbose] Retry ${attempt + 1}/${this.maxRetries} after ${Math.round(delayMs)}ms (status ${response.status})`
130
+ );
131
+ }
132
+ try {
133
+ await response.body?.cancel();
134
+ } catch {
135
+ }
136
+ await sleep(delayMs);
137
+ continue;
138
+ }
139
+ const text = await response.text();
140
+ throw new OpenSeaAPIError(response.status, text, path);
141
+ }
142
+ }
88
143
  };
89
144
  var OpenSeaAPIError = class extends Error {
90
145
  constructor(statusCode, responseBody, path) {
@@ -370,14 +425,24 @@ function formatToon(data) {
370
425
  }
371
426
 
372
427
  // src/output.ts
428
+ var _outputOptions = {};
429
+ function setOutputOptions(options) {
430
+ _outputOptions = options;
431
+ }
373
432
  function formatOutput(data, format) {
433
+ const processed = _outputOptions.fields ? filterFields(data, _outputOptions.fields) : data;
434
+ let result;
374
435
  if (format === "table") {
375
- return formatTable(data);
436
+ result = formatTable(processed);
437
+ } else if (format === "toon") {
438
+ result = formatToon(processed);
439
+ } else {
440
+ result = JSON.stringify(processed, null, 2);
376
441
  }
377
- if (format === "toon") {
378
- return formatToon(data);
442
+ if (_outputOptions.maxLines != null) {
443
+ result = truncateOutput(result, _outputOptions.maxLines);
379
444
  }
380
- return JSON.stringify(data, null, 2);
445
+ return result;
381
446
  }
382
447
  function formatTable(data) {
383
448
  if (Array.isArray(data)) {
@@ -413,6 +478,31 @@ function formatTable(data) {
413
478
  }
414
479
  return String(data);
415
480
  }
481
+ function pickFields(obj, fields) {
482
+ const result = {};
483
+ for (const field of fields) {
484
+ if (field in obj) {
485
+ result[field] = obj[field];
486
+ }
487
+ }
488
+ return result;
489
+ }
490
+ function filterFields(data, fields) {
491
+ if (Array.isArray(data)) {
492
+ return data.map((item) => filterFields(item, fields));
493
+ }
494
+ if (data && typeof data === "object") {
495
+ return pickFields(data, fields);
496
+ }
497
+ return data;
498
+ }
499
+ function truncateOutput(text, maxLines) {
500
+ const lines = text.split("\n");
501
+ if (lines.length <= maxLines) return text;
502
+ const omitted = lines.length - maxLines;
503
+ return lines.slice(0, maxLines).join("\n") + `
504
+ ... (${omitted} more line${omitted === 1 ? "" : "s"})`;
505
+ }
416
506
 
417
507
  // src/commands/accounts.ts
418
508
  function accountsCommand(getClient2, getFormat2) {
@@ -549,10 +639,88 @@ function eventsCommand(getClient2, getFormat2) {
549
639
  return cmd;
550
640
  }
551
641
 
552
- // src/commands/listings.ts
642
+ // src/commands/health.ts
553
643
  import { Command as Command4 } from "commander";
644
+
645
+ // src/health.ts
646
+ async function checkHealth(client) {
647
+ const keyPrefix = client.getApiKeyPrefix();
648
+ try {
649
+ await client.get("/api/v2/collections", { limit: 1 });
650
+ } catch (error) {
651
+ let message;
652
+ if (error instanceof OpenSeaAPIError) {
653
+ message = error.statusCode === 429 ? "Rate limited: too many requests" : `API error (${error.statusCode}): ${error.responseBody}`;
654
+ } else {
655
+ message = `Network error: ${error.message}`;
656
+ }
657
+ return {
658
+ status: "error",
659
+ key_prefix: keyPrefix,
660
+ authenticated: false,
661
+ rate_limited: error instanceof OpenSeaAPIError && error.statusCode === 429,
662
+ message
663
+ };
664
+ }
665
+ try {
666
+ await client.get("/api/v2/listings/collection/boredapeyachtclub/all", {
667
+ limit: 1
668
+ });
669
+ return {
670
+ status: "ok",
671
+ key_prefix: keyPrefix,
672
+ authenticated: true,
673
+ rate_limited: false,
674
+ message: "Connectivity and authentication are working"
675
+ };
676
+ } catch (error) {
677
+ if (error instanceof OpenSeaAPIError) {
678
+ if (error.statusCode === 429) {
679
+ return {
680
+ status: "error",
681
+ key_prefix: keyPrefix,
682
+ authenticated: false,
683
+ rate_limited: true,
684
+ message: "Rate limited: too many requests"
685
+ };
686
+ }
687
+ if (error.statusCode === 401 || error.statusCode === 403) {
688
+ return {
689
+ status: "error",
690
+ key_prefix: keyPrefix,
691
+ authenticated: false,
692
+ rate_limited: false,
693
+ message: `Authentication failed (${error.statusCode}): invalid API key`
694
+ };
695
+ }
696
+ }
697
+ return {
698
+ status: "ok",
699
+ key_prefix: keyPrefix,
700
+ authenticated: false,
701
+ rate_limited: false,
702
+ message: "Connectivity is working but authentication could not be verified"
703
+ };
704
+ }
705
+ }
706
+
707
+ // src/commands/health.ts
708
+ function healthCommand(getClient2, getFormat2) {
709
+ const cmd = new Command4("health").description("Check API connectivity and authentication").action(async () => {
710
+ const client = getClient2();
711
+ const result = await checkHealth(client);
712
+ console.log(formatOutput(result, getFormat2()));
713
+ if (result.status === "error") {
714
+ process.exit(result.rate_limited ? 3 : 1);
715
+ }
716
+ });
717
+ return cmd;
718
+ }
719
+
720
+ // src/commands/listings.ts
721
+ import { Command as Command5 } from "commander";
554
722
  function listingsCommand(getClient2, getFormat2) {
555
- const cmd = new Command4("listings").description("Query NFT listings");
723
+ const cmd = new Command5("listings").description("Query NFT listings");
556
724
  cmd.command("all").description("Get all listings for a collection").argument("<collection>", "Collection slug").option("--limit <limit>", "Number of results", "20").option("--next <cursor>", "Pagination cursor").action(
557
725
  async (collection, options) => {
558
726
  const client = getClient2();
@@ -584,9 +752,9 @@ function listingsCommand(getClient2, getFormat2) {
584
752
  }
585
753
 
586
754
  // src/commands/nfts.ts
587
- import { Command as Command5 } from "commander";
755
+ import { Command as Command6 } from "commander";
588
756
  function nftsCommand(getClient2, getFormat2) {
589
- const cmd = new Command5("nfts").description("Query NFTs");
757
+ const cmd = new Command6("nfts").description("Query NFTs");
590
758
  cmd.command("get").description("Get a single NFT").argument("<chain>", "Chain (e.g. ethereum, base)").argument("<contract>", "Contract address").argument("<token-id>", "Token ID").action(async (chain, contract, tokenId) => {
591
759
  const client = getClient2();
592
760
  const result = await client.get(
@@ -654,9 +822,9 @@ function nftsCommand(getClient2, getFormat2) {
654
822
  }
655
823
 
656
824
  // src/commands/offers.ts
657
- import { Command as Command6 } from "commander";
825
+ import { Command as Command7 } from "commander";
658
826
  function offersCommand(getClient2, getFormat2) {
659
- const cmd = new Command6("offers").description("Query NFT offers");
827
+ const cmd = new Command7("offers").description("Query NFT offers");
660
828
  cmd.command("all").description("Get all offers for a collection").argument("<collection>", "Collection slug").option("--limit <limit>", "Number of results", "20").option("--next <cursor>", "Pagination cursor").action(
661
829
  async (collection, options) => {
662
830
  const client = getClient2();
@@ -700,9 +868,9 @@ function offersCommand(getClient2, getFormat2) {
700
868
  }
701
869
 
702
870
  // src/commands/search.ts
703
- import { Command as Command7 } from "commander";
871
+ import { Command as Command8 } from "commander";
704
872
  function searchCommand(getClient2, getFormat2) {
705
- const cmd = new Command7("search").description("Search across collections, tokens, NFTs, and accounts").argument("<query>", "Search query").option(
873
+ const cmd = new Command8("search").description("Search across collections, tokens, NFTs, and accounts").argument("<query>", "Search query").option(
706
874
  "--types <types>",
707
875
  "Filter by type (comma-separated: collection,nft,token,account)"
708
876
  ).option("--chains <chains>", "Filter by chains (comma-separated)").option("--limit <limit>", "Number of results", "20").action(
@@ -729,9 +897,9 @@ function searchCommand(getClient2, getFormat2) {
729
897
  }
730
898
 
731
899
  // src/commands/swaps.ts
732
- import { Command as Command8 } from "commander";
900
+ import { Command as Command9 } from "commander";
733
901
  function swapsCommand(getClient2, getFormat2) {
734
- const cmd = new Command8("swaps").description(
902
+ const cmd = new Command9("swaps").description(
735
903
  "Get swap quotes for token trading"
736
904
  );
737
905
  cmd.command("quote").description(
@@ -771,9 +939,9 @@ function swapsCommand(getClient2, getFormat2) {
771
939
  }
772
940
 
773
941
  // src/commands/tokens.ts
774
- import { Command as Command9 } from "commander";
942
+ import { Command as Command10 } from "commander";
775
943
  function tokensCommand(getClient2, getFormat2) {
776
- const cmd = new Command9("tokens").description(
944
+ const cmd = new Command10("tokens").description(
777
945
  "Query trending tokens, top tokens, and token details"
778
946
  );
779
947
  cmd.command("trending").description("Get trending tokens based on OpenSea's trending score").option("--chains <chains>", "Comma-separated list of chains to filter by").option("--limit <limit>", "Number of results (max 100)", "20").option("--next <cursor>", "Pagination cursor").action(
@@ -817,6 +985,9 @@ function tokensCommand(getClient2, getFormat2) {
817
985
  }
818
986
 
819
987
  // src/cli.ts
988
+ var EXIT_API_ERROR = 1;
989
+ var EXIT_AUTH_ERROR = 2;
990
+ var EXIT_RATE_LIMITED = 3;
820
991
  var BANNER = `
821
992
  ____ _____
822
993
  / __ \\ / ____|
@@ -827,8 +998,11 @@ var BANNER = `
827
998
  | |
828
999
  |_|
829
1000
  `;
830
- var program = new Command10();
831
- program.name("opensea").description("OpenSea CLI - Query the OpenSea API from the command line").version(process.env.npm_package_version ?? "0.0.0").addHelpText("before", BANNER).option("--api-key <key>", "OpenSea API key (or set OPENSEA_API_KEY env var)").option("--chain <chain>", "Default chain", "ethereum").option("--format <format>", "Output format (json, table, or toon)", "json").option("--base-url <url>", "API base URL").option("--timeout <ms>", "Request timeout in milliseconds", "30000").option("--verbose", "Log request and response info to stderr");
1001
+ var program = new Command11();
1002
+ program.name("opensea").description("OpenSea CLI - Query the OpenSea API from the command line").version(process.env.npm_package_version ?? "0.0.0").addHelpText("before", BANNER).option("--api-key <key>", "OpenSea API key (or set OPENSEA_API_KEY env var)").option("--chain <chain>", "Default chain", "ethereum").option("--format <format>", "Output format (json, table, or toon)", "json").option("--base-url <url>", "API base URL").option("--timeout <ms>", "Request timeout in milliseconds", "30000").option("--verbose", "Log request and response info to stderr").option(
1003
+ "--fields <fields>",
1004
+ "Comma-separated list of fields to include in output"
1005
+ ).option("--max-lines <lines>", "Truncate output after N lines").option("--max-retries <n>", "Max retries on 429/5xx (0 to disable)", "3").option("--no-retry", "Disable request retries");
832
1006
  function getClient() {
833
1007
  const opts = program.opts();
834
1008
  const apiKey = opts.apiKey ?? process.env.OPENSEA_API_KEY;
@@ -836,14 +1010,16 @@ function getClient() {
836
1010
  console.error(
837
1011
  "Error: API key required. Use --api-key or set OPENSEA_API_KEY environment variable."
838
1012
  );
839
- process.exit(2);
1013
+ process.exit(EXIT_AUTH_ERROR);
840
1014
  }
1015
+ const maxRetries = opts.retry ? parseIntOption(opts.maxRetries, "--max-retries") : 0;
841
1016
  return new OpenSeaClient({
842
1017
  apiKey,
843
1018
  chain: opts.chain,
844
1019
  baseUrl: opts.baseUrl,
845
1020
  timeout: parseIntOption(opts.timeout, "--timeout"),
846
- verbose: opts.verbose
1021
+ verbose: opts.verbose,
1022
+ maxRetries
847
1023
  });
848
1024
  }
849
1025
  function getFormat() {
@@ -852,6 +1028,21 @@ function getFormat() {
852
1028
  if (opts.format === "toon") return "toon";
853
1029
  return "json";
854
1030
  }
1031
+ program.hook("preAction", () => {
1032
+ const opts = program.opts();
1033
+ let maxLines;
1034
+ if (opts.maxLines) {
1035
+ maxLines = parseIntOption(opts.maxLines, "--max-lines");
1036
+ if (maxLines < 1) {
1037
+ console.error("Error: --max-lines must be >= 1");
1038
+ process.exit(2);
1039
+ }
1040
+ }
1041
+ setOutputOptions({
1042
+ fields: opts.fields?.split(",").map((f) => f.trim()),
1043
+ maxLines
1044
+ });
1045
+ });
855
1046
  program.addCommand(collectionsCommand(getClient, getFormat));
856
1047
  program.addCommand(nftsCommand(getClient, getFormat));
857
1048
  program.addCommand(listingsCommand(getClient, getFormat));
@@ -861,15 +1052,17 @@ program.addCommand(accountsCommand(getClient, getFormat));
861
1052
  program.addCommand(tokensCommand(getClient, getFormat));
862
1053
  program.addCommand(searchCommand(getClient, getFormat));
863
1054
  program.addCommand(swapsCommand(getClient, getFormat));
1055
+ program.addCommand(healthCommand(getClient, getFormat));
864
1056
  async function main() {
865
1057
  try {
866
1058
  await program.parseAsync(process.argv);
867
1059
  } catch (error) {
868
1060
  if (error instanceof OpenSeaAPIError) {
1061
+ const isRateLimited = error.statusCode === 429;
869
1062
  console.error(
870
1063
  JSON.stringify(
871
1064
  {
872
- error: "API Error",
1065
+ error: isRateLimited ? "Rate Limited" : "API Error",
873
1066
  status: error.statusCode,
874
1067
  path: error.path,
875
1068
  message: error.responseBody
@@ -878,7 +1071,7 @@ async function main() {
878
1071
  2
879
1072
  )
880
1073
  );
881
- process.exit(1);
1074
+ process.exit(isRateLimited ? EXIT_RATE_LIMITED : EXIT_API_ERROR);
882
1075
  }
883
1076
  const label = error instanceof TypeError ? "Network Error" : error.name;
884
1077
  console.error(
@@ -891,7 +1084,7 @@ async function main() {
891
1084
  2
892
1085
  )
893
1086
  );
894
- process.exit(1);
1087
+ process.exit(EXIT_API_ERROR);
895
1088
  }
896
1089
  }
897
1090
  main();