@feralfile/cli 2.2.1 → 2.2.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/dist/index.js CHANGED
@@ -9,7 +9,15 @@ process.on('warning', (warning) => {
9
9
  console.warn(warning.name + ': ' + warning.message);
10
10
  });
11
11
  require("dotenv/config");
12
+ const net_1 = require("net");
12
13
  const commander_1 = require("commander");
14
+ // Node's happy-eyeballs gives each address-family connect attempt 250 ms by
15
+ // default. That is shorter than a single round trip to a far-away API host
16
+ // (e.g. api.raster.art from Asia is ~250 ms RTT), so on networks with broken
17
+ // IPv6 the IPv4 attempt gets cut off mid-handshake, the IPv6 fallback has no
18
+ // route, and fetch fails or stalls intermittently (#97). 2.5 s per attempt is
19
+ // still fast-failing but no longer races the speed of light.
20
+ (0, net_1.setDefaultAutoSelectFamilyAttemptTimeout)(2500);
13
21
  const fs_1 = require("fs");
14
22
  const path_1 = require("path");
15
23
  const setup_1 = require("./src/commands/setup");
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feralfile/cli",
3
- "version": "2.2.1",
3
+ "version": "2.2.2",
4
4
  "description": "CLI for building, signing, and playing DP-1 playlists of digital art on Feral File devices",
5
5
  "repository": {
6
6
  "type": "git",
@@ -168,6 +168,13 @@ async function runResolvedTarget(target, options) {
168
168
  return;
169
169
  }
170
170
  console.log(chalk_1.default.dim(`Indexing ${tokens.length} token${tokens.length === 1 ? '' : 's'} via FF indexer...`));
171
+ // Previously-unseen tokens make the indexer warm renditions by polling —
172
+ // minutes of wall time on a large series. Say so upfront (the --limit
173
+ // warming hint used to appear only after a failure) and render per-batch
174
+ // progress so a long index never reads as a hang.
175
+ if (tokens.length > 10) {
176
+ console.log(chalk_1.default.dim(' First-time tokens can take the indexer a while to warm; use `--limit 5` for a faster first pass.'));
177
+ }
171
178
  // Second positional arg on getNFTTokenInfoBatch is `duration` (DP-1 item
172
179
  // display seconds), not concurrency — concurrency is hardcoded inside.
173
180
  // Omit it for auto timing: video/audio items carry no duration and play
@@ -176,7 +183,14 @@ async function runResolvedTarget(target, options) {
176
183
  chain: t.chain,
177
184
  contractAddress: t.contract,
178
185
  tokenId: t.tokenId,
179
- })));
186
+ })), undefined,
187
+ // Unconditional: suppressing the done===total call meant a one-batch
188
+ // index (including the recommended --limit 5 warm-up) printed nothing
189
+ // and multi-batch runs never showed completion. The final N/N line IS
190
+ // the "indexing finished" signal.
191
+ (done, total) => {
192
+ console.log(chalk_1.default.dim(` ${done}/${total} tokens indexed...`));
193
+ });
180
194
  // FF-indexer bypass for Raster-minted tokens. The indexer doesn't carry
181
195
  // Raster's tokens, so it returns nothing — but Raster handed us each
182
196
  // token's playable `media.contentUrl`. Build DP-1 items straight from
@@ -223,10 +237,20 @@ async function resolveTarget(input, parsed, skipPrompt, limitOption) {
223
237
  return resolveCoords(parsed.coords);
224
238
  }
225
239
  if (parsed.kind === 'ff-url') {
226
- if (parsed.urlKind === 'show') {
227
- const target = await resolveTokenListInput(input, resolverLimitFromOption(limitOption), `Feral File show ${parsed.identifier}`);
240
+ // Shows and series both resolve through the source-resolver's Feral File
241
+ // site module, which enumerates exactly the tokens the page describes
242
+ // (series slug → /api/series → /api/artworks?seriesID). The old series
243
+ // path resolved ONE token via ff-marketplace and then asked Raster, which
244
+ // expands to the token's parent artwork — so a single-edition series page
245
+ // (e.g. a Display Edition) surprisingly built the sibling collection's
246
+ // full token list. A series URL means the user pointed at a specific
247
+ // series; build exactly that. Artwork URLs keep the coords→Raster path:
248
+ // a single-token page carries no series intent to preserve.
249
+ if (parsed.urlKind === 'show' || parsed.urlKind === 'series') {
250
+ const label = parsed.urlKind === 'show' ? 'show' : 'series';
251
+ const target = await resolveTokenListInput(input, resolverLimitFromOption(limitOption), `Feral File ${label} ${parsed.identifier}`);
228
252
  if (target === null) {
229
- throw new Error(`Feral File: no supported tokens found for show "${parsed.identifier}".`);
253
+ throw new Error(`Feral File: no supported tokens found for ${label} "${parsed.identifier}".`);
230
254
  }
231
255
  return target;
232
256
  }
@@ -326,9 +350,25 @@ async function resolveTokenListInput(input, limit, fallbackTitle = 'Resolved sou
326
350
  /**
327
351
  * Resolve on-chain coords to a target. If Raster doesn't index this token,
328
352
  * fall back to single-token mode so we still build something playable.
353
+ *
354
+ * Raster being unreachable gets the same fallback: it only enriches the find
355
+ * (series enumeration, artist labels) — the coords in hand are enough to
356
+ * build a playable single-token playlist, and a network blip on one optional
357
+ * dependency must not kill the whole command (#97).
329
358
  */
330
359
  async function resolveCoords(coords) {
331
- const summary = await (0, raster_client_1.resolveTokenToArtwork)(coords.chain, coords.contract, coords.tokenId);
360
+ let summary;
361
+ try {
362
+ summary = await (0, raster_client_1.resolveTokenToArtwork)(coords.chain, coords.contract, coords.tokenId);
363
+ }
364
+ catch (error) {
365
+ if (!(error instanceof raster_client_1.RasterUnreachableError)) {
366
+ throw error;
367
+ }
368
+ console.log(chalk_1.default.yellow(` ${error.message}`));
369
+ console.log(chalk_1.default.dim(' Continuing without Raster — building a one-item playlist.'));
370
+ return { kind: 'single', coords };
371
+ }
332
372
  if (summary === null) {
333
373
  return { kind: 'single', coords };
334
374
  }
@@ -46,6 +46,7 @@ var __importStar = (this && this.__importStar) || (function () {
46
46
  })();
47
47
  Object.defineProperty(exports, "__esModule", { value: true });
48
48
  exports.resolveArtBlocksCollection = resolveArtBlocksCollection;
49
+ const http_1 = require("./http");
49
50
  const logger = __importStar(require("../logger"));
50
51
  const user_agent_1 = require("./user-agent");
51
52
  const AB_GRAPHQL = 'https://artblocks-mainnet.hasura.app/v1/graphql';
@@ -64,7 +65,7 @@ async function resolveArtBlocksCollection(slug) {
64
65
  ' tokens(limit: 1, order_by: { invocation: asc }) { token_id }' +
65
66
  ' }' +
66
67
  '}';
67
- const response = await fetch(AB_GRAPHQL, {
68
+ const response = await (0, http_1.fetchWithTimeout)(AB_GRAPHQL, {
68
69
  method: 'POST',
69
70
  headers: { 'Content-Type': 'application/json', 'User-Agent': user_agent_1.USER_AGENT },
70
71
  body: JSON.stringify({ query, variables: { slug } }),
@@ -3,6 +3,7 @@
3
3
  * Utilities to fetch playlists from DP1 Feed API
4
4
  * API: https://github.com/display-protocol/dp1-feed
5
5
  */
6
+ const { fetchWithTimeout } = require('./http');
6
7
  const chalk = require('chalk');
7
8
  const fuzzysort = require('fuzzysort');
8
9
  const { getFeedConfig } = require('../config');
@@ -42,7 +43,7 @@ async function fetchPlaylistsFromFeed(feedUrl, limit = 100) {
42
43
  try {
43
44
  // API has a maximum limit of 100
44
45
  const validLimit = Math.min(limit, 100);
45
- const response = await fetch(`${feedUrl}/playlists?limit=${validLimit}&sort=-created`);
46
+ const response = await fetchWithTimeout(`${feedUrl}/playlists?limit=${validLimit}&sort=-created`);
46
47
  if (!response.ok) {
47
48
  console.log(chalk.yellow(` Feed ${feedUrl} returned ${response.status}`));
48
49
  return {
@@ -97,7 +98,7 @@ async function fetchPlaylistsWithPagination(feedUrl, searchTerm, pageSize = 50,
97
98
  const remainingItems = maxItems - totalFetched;
98
99
  const currentLimit = Math.min(pageSize, remainingItems, 100);
99
100
  try {
100
- const response = await fetch(`${feedUrl}/playlists?limit=${currentLimit}&offset=${offset}&sort=-created`);
101
+ const response = await fetchWithTimeout(`${feedUrl}/playlists?limit=${currentLimit}&offset=${offset}&sort=-created`);
101
102
  reachable = true;
102
103
  if (!response.ok) {
103
104
  if (response.status === 404 || response.status === 400) {
@@ -297,7 +298,7 @@ async function getPlaylistById(idOrSlug, feedUrl = null) {
297
298
  // Try each feed URL until we find the playlist
298
299
  for (const url of feedUrls) {
299
300
  try {
300
- const response = await fetch(`${url}/playlists/${idOrSlug}`);
301
+ const response = await fetchWithTimeout(`${url}/playlists/${idOrSlug}`);
301
302
  if (response.ok) {
302
303
  const playlist = await response.json();
303
304
  return playlist;
@@ -10,6 +10,7 @@
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
11
  exports.resolveFeralFileArtwork = resolveFeralFileArtwork;
12
12
  exports.parseFeralFileArtworkId = parseFeralFileArtworkId;
13
+ const http_1 = require("./http");
13
14
  const user_agent_1 = require("./user-agent");
14
15
  /**
15
16
  * resolveFeralFileArtwork resolves a public Feral File artwork id into indexer
@@ -19,7 +20,7 @@ const user_agent_1 = require("./user-agent");
19
20
  * @param fetchImpl - Fetch implementation, injected by tests.
20
21
  * @returns Token coordinates suitable for ff-indexer-v2 lookup.
21
22
  */
22
- async function resolveFeralFileArtwork(artworkId, fetchImpl = fetch) {
23
+ async function resolveFeralFileArtwork(artworkId, fetchImpl = http_1.defaultDeadlineFetch) {
23
24
  const normalizedArtworkId = artworkId.trim();
24
25
  if (!normalizedArtworkId) {
25
26
  throw new Error('Feral File artwork id is required');
@@ -50,6 +50,7 @@ exports.resolveFeralFileToken = resolveFeralFileToken;
50
50
  * Show URLs are rejected: a single exhibition spans multiple series, which
51
51
  * is wider than v1 supports.
52
52
  */
53
+ const http_1 = require("./http");
53
54
  const logger = __importStar(require("../logger"));
54
55
  const feral_file_artwork_1 = require("./feral-file-artwork");
55
56
  const user_agent_1 = require("./user-agent");
@@ -57,7 +58,7 @@ const FF_API_BASE = 'https://feralfile.com/api';
57
58
  async function ffFetch(path) {
58
59
  const url = `${FF_API_BASE}${path}`;
59
60
  logger.debug(`[FF API] GET ${url}`);
60
- const response = await fetch(url, {
61
+ const response = await (0, http_1.fetchWithTimeout)(url, {
61
62
  headers: { 'User-Agent': user_agent_1.USER_AGENT, Accept: 'application/json' },
62
63
  });
63
64
  if (!response.ok) {
@@ -38,6 +38,7 @@ var __importStar = (this && this.__importStar) || (function () {
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.resolveConfiguredDevice = resolveConfiguredDevice;
40
40
  exports.assertFF1CommandCompatibility = assertFF1CommandCompatibility;
41
+ const http_1 = require("./http");
41
42
  const config_1 = require("../config");
42
43
  const device_lookup_1 = require("./device-lookup");
43
44
  const logger = __importStar(require("../logger"));
@@ -113,7 +114,9 @@ function resolveConfiguredDevice(deviceName, options = {}) {
113
114
  * const result = await assertFF1CommandCompatibility(device, 'displayPlaylist');
114
115
  */
115
116
  async function assertFF1CommandCompatibility(device, command, options = {}) {
116
- const fetchFn = options.fetchFn || globalThis.fetch.bind(globalThis);
117
+ // Default probe fetch rides the shared deadline: a device that accepts
118
+ // the TCP connection but never answers must not hang `ff-cli ssh`.
119
+ const fetchFn = options.fetchFn || ((input, init) => (0, http_1.fetchWithTimeout)(input, init ?? {}));
117
120
  const policy = getCommandPolicy(command);
118
121
  const versionResult = await detectFF1VersionSafely(device.host, buildVersionHeaders(device), fetchFn);
119
122
  return resolveCompatibility(device, command, policy, versionResult);
@@ -40,6 +40,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
40
40
  exports.isTransientDeviceNetworkError = isTransientDeviceNetworkError;
41
41
  exports.parseRetryAfterMs = parseRetryAfterMs;
42
42
  exports.sendPlaylistToDevice = sendPlaylistToDevice;
43
+ const http_1 = require("./http");
43
44
  const logger = __importStar(require("../logger"));
44
45
  const config_1 = require("../config");
45
46
  const ff1_compatibility_1 = require("./ff1-compatibility");
@@ -178,7 +179,9 @@ function parseRetryAfterMs(headerValue) {
178
179
  */
179
180
  async function sendPlaylistToDevice({ playlist, deviceName }, overrides = {}) {
180
181
  const dependencies = {
181
- fetchFn: overrides.fetchFn ?? globalThis.fetch.bind(globalThis),
182
+ // Production default rides the shared deadline; injected test fetches
183
+ // are preserved untouched (#101 review).
184
+ fetchFn: overrides.fetchFn ?? http_1.defaultDeadlineFetch,
182
185
  getTopicIdFn: overrides.getTopicIdFn ?? topic_store_1.getTopicId,
183
186
  getRelayerConfigFn: overrides.getRelayerConfigFn ?? config_1.getFF1RelayerConfig,
184
187
  waitFn: overrides.waitFn ?? waitForRetry,
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.sendPlaylistViaRelayer = sendPlaylistViaRelayer;
4
+ const http_1 = require("./http");
4
5
  function nestedOk(value) {
5
6
  if (!value || typeof value !== 'object') {
6
7
  return undefined;
@@ -26,7 +27,8 @@ function nestedOk(value) {
26
27
  * and JSON parsing errors.
27
28
  */
28
29
  async function sendPlaylistViaRelayer(input) {
29
- const fetchFn = input.fetchFn ?? globalThis.fetch.bind(globalThis);
30
+ // Production default rides the shared deadline (#101 review).
31
+ const fetchFn = input.fetchFn ?? http_1.defaultDeadlineFetch;
30
32
  let url;
31
33
  try {
32
34
  url = new URL('/api/cast', input.baseUrl);
@@ -51,6 +51,7 @@ var __importStar = (this && this.__importStar) || (function () {
51
51
  Object.defineProperty(exports, "__esModule", { value: true });
52
52
  exports.resolveFxhashIteration = resolveFxhashIteration;
53
53
  exports.resolveFxhashProject = resolveFxhashProject;
54
+ const http_1 = require("./http");
54
55
  const logger = __importStar(require("../logger"));
55
56
  const user_agent_1 = require("./user-agent");
56
57
  const FXHASH_GRAPHQL = 'https://api.fxhash.xyz/graphql';
@@ -63,7 +64,7 @@ const FXHASH_GRAPHQL = 'https://api.fxhash.xyz/graphql';
63
64
  async function resolveFxhashIteration(slug) {
64
65
  logger.debug(`[fxhash] Resolving iteration slug "${slug}"`);
65
66
  const query = 'query ($slug: String!) { objkt(slug: $slug) { gentkContractAddress onChainId } }';
66
- const response = await fetch(FXHASH_GRAPHQL, {
67
+ const response = await (0, http_1.fetchWithTimeout)(FXHASH_GRAPHQL, {
67
68
  method: 'POST',
68
69
  headers: { 'Content-Type': 'application/json', 'User-Agent': user_agent_1.USER_AGENT },
69
70
  body: JSON.stringify({ query, variables: { slug } }),
@@ -115,7 +116,7 @@ async function resolveFxhashProject(slug) {
115
116
  ' }' +
116
117
  ' }' +
117
118
  '}';
118
- const response = await fetch(FXHASH_GRAPHQL, {
119
+ const response = await (0, http_1.fetchWithTimeout)(FXHASH_GRAPHQL, {
119
120
  method: 'POST',
120
121
  headers: { 'Content-Type': 'application/json', 'User-Agent': user_agent_1.USER_AGENT },
121
122
  body: JSON.stringify({ query, variables: { slug } }),
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.TopicHandoffReceiver = void 0;
4
+ const http_1 = require("./http");
4
5
  const handoff_crypto_1 = require("./handoff-crypto");
5
6
  const DEFAULT_HANDOFF_URL = 'https://handoff.feralfile.com';
6
7
  function brokerBaseUrl(value) {
@@ -69,7 +70,8 @@ class TopicHandoffReceiver {
69
70
  }
70
71
  /** Ask the handoff broker for a one-time six-digit pairing channel. */
71
72
  static async create(options = {}) {
72
- const fetchFn = options.fetchFn ?? globalThis.fetch.bind(globalThis);
73
+ // Production default rides the shared deadline (#101 review).
74
+ const fetchFn = options.fetchFn ?? http_1.defaultDeadlineFetch;
73
75
  const baseUrl = brokerBaseUrl(options.baseUrl);
74
76
  const keyPair = await (0, handoff_crypto_1.generateHandoffKeyPair)();
75
77
  const publicKey = await (0, handoff_crypto_1.exportHandoffPublicJwk)(keyPair.publicKey);
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ /**
3
+ * Shared fetch-with-timeout for every outbound HTTP call in the CLI.
4
+ *
5
+ * Bare `fetch` has no deadline: a stalled connect or an unanswered request
6
+ * hangs the command silently — the failure mode agents can least diagnose,
7
+ * and the one behind #97. Every resolver and indexer call goes through here
8
+ * so "silent hang" is structurally impossible, not per-call-site discipline.
9
+ *
10
+ * Callers that pass their own `signal` keep it (composed with the deadline
11
+ * via AbortSignal.any), so cancellation semantics are never silently
12
+ * replaced by a timeout.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.defaultDeadlineFetch = exports.DEFAULT_HTTP_TIMEOUT_MS = void 0;
16
+ exports.fetchWithTimeout = fetchWithTimeout;
17
+ exports.DEFAULT_HTTP_TIMEOUT_MS = 20_000;
18
+ function fetchWithTimeout(input, init = {}, timeoutMs = exports.DEFAULT_HTTP_TIMEOUT_MS) {
19
+ const deadline = AbortSignal.timeout(timeoutMs);
20
+ const signals = [deadline];
21
+ if (init.signal) {
22
+ signals.push(init.signal);
23
+ }
24
+ else if (input instanceof Request && input.signal) {
25
+ // A Request input carries its own abort signal; passing only the
26
+ // deadline in init would silently REPLACE it (init wins over the
27
+ // request's signal in fetch), stripping the caller's cancellation.
28
+ signals.push(input.signal);
29
+ }
30
+ const signal = signals.length > 1 ? AbortSignal.any(signals) : deadline;
31
+ return fetch(input, { ...init, signal });
32
+ }
33
+ /**
34
+ * Drop-in `fetch` for production defaults of injectable-fetch modules:
35
+ * identical signature, deadline attached. Modules that accept a `fetchFn`
36
+ * override default to this instead of bare `globalThis.fetch`, so tests
37
+ * keep full injection control while production can never hang.
38
+ */
39
+ const defaultDeadlineFetch = (input, init) => fetchWithTimeout(input, init ?? {});
40
+ exports.defaultDeadlineFetch = defaultDeadlineFetch;
@@ -58,6 +58,7 @@ var __importStar = (this && this.__importStar) || (function () {
58
58
  })();
59
59
  Object.defineProperty(exports, "__esModule", { value: true });
60
60
  exports.resolveNeortArt = resolveNeortArt;
61
+ const http_1 = require("./http");
61
62
  const logger = __importStar(require("../logger"));
62
63
  const user_agent_1 = require("./user-agent");
63
64
  const NEORT_API_BASE = 'https://api.neort.io/v1';
@@ -70,7 +71,7 @@ const NEORT_CDN_BASE = 'https://d32h66pp7fue57.cloudfront.net';
70
71
  */
71
72
  async function resolveNeortArt(id) {
72
73
  logger.debug(`[neort] Resolving art id "${id}"`);
73
- const response = await fetch(`${NEORT_API_BASE}/art/${encodeURIComponent(id)}`, {
74
+ const response = await (0, http_1.fetchWithTimeout)(`${NEORT_API_BASE}/art/${encodeURIComponent(id)}`, {
74
75
  headers: { 'User-Agent': user_agent_1.USER_AGENT, Accept: 'application/json' },
75
76
  });
76
77
  if (!response.ok) {
@@ -4,6 +4,7 @@
4
4
  * to retrieve comprehensive token information.
5
5
  */
6
6
  const GRAPHQL_ENDPOINT = 'https://indexer.feralfile.com/graphql';
7
+ const { fetchWithTimeout } = require('./http');
7
8
  const logger = require('../logger');
8
9
  const { applyItemTiming } = require('./playlist-builder');
9
10
  // Polling configuration (in milliseconds)
@@ -174,7 +175,7 @@ async function queryTokens(params = {}) {
174
175
  const headers = { 'Content-Type': 'application/json' };
175
176
  logger.debug('[NFT Indexer] Querying tokens:', { token_cids, owners, limit, offset });
176
177
  logger.debug('[NFT Indexer] GraphQL query:', query);
177
- const response = await fetch(GRAPHQL_ENDPOINT, {
178
+ const response = await fetchWithTimeout(GRAPHQL_ENDPOINT, {
178
179
  method: 'POST',
179
180
  headers,
180
181
  body: JSON.stringify({ query }),
@@ -598,7 +599,7 @@ async function getNFTTokenInfoSingle(params, duration, options = {}) {
598
599
  * @param {number} duration - Display duration in seconds
599
600
  * @returns {Promise<Array>} Array of DP1 items
600
601
  */
601
- async function getNFTTokenInfoBatch(tokens, duration) {
602
+ async function getNFTTokenInfoBatch(tokens, duration, onProgress) {
602
603
  logger.info(`[NFT Indexer] 📦 Starting batch processing for ${tokens.length} token(s)...`);
603
604
  logger.debug('[NFT Indexer] Batch tokens:', tokens);
604
605
  const results = [];
@@ -620,6 +621,19 @@ async function getNFTTokenInfoBatch(tokens, duration) {
620
621
  const failed = batchResults.filter((r) => !r.success).length;
621
622
  logger.info(`[NFT Indexer] Batch complete: ${successful} success, ${failed} failed`);
622
623
  results.push(...batchResults);
624
+ // Indexing previously-unseen tokens can take minutes (the indexer warms
625
+ // renditions by polling); without user-visible progress the CLI reads as
626
+ // hung. logger.info above is verbose-gated, so callers that face a human
627
+ // pass onProgress to render it. Errors in the callback must never break
628
+ // the batch — reporting is strictly best-effort.
629
+ if (typeof onProgress === 'function') {
630
+ try {
631
+ onProgress(results.length, tokens.length);
632
+ }
633
+ catch {
634
+ /* progress display is best-effort */
635
+ }
636
+ }
623
637
  }
624
638
  logger.info(`[NFT Indexer] ✓ Batch processing complete: ${results.length} total results`);
625
639
  const successCount = results.filter((r) => r.success && r.item).length;
@@ -687,7 +701,7 @@ async function triggerIndexingAsync(chain, contractAddress, tokenId) {
687
701
  token_cids: [tokenCID],
688
702
  };
689
703
  const headers = { 'Content-Type': 'application/json' };
690
- const response = await fetch(GRAPHQL_ENDPOINT, {
704
+ const response = await fetchWithTimeout(GRAPHQL_ENDPOINT, {
691
705
  method: 'POST',
692
706
  headers,
693
707
  body: JSON.stringify({ query: mutation, variables }),
@@ -742,7 +756,7 @@ async function queryJobStatus(jobId) {
742
756
  const query = JOB_STATUS_QUERY;
743
757
  const variables = { job_id: id };
744
758
  const headers = { 'Content-Type': 'application/json' };
745
- const response = await fetch(GRAPHQL_ENDPOINT, {
759
+ const response = await fetchWithTimeout(GRAPHQL_ENDPOINT, {
746
760
  method: 'POST',
747
761
  headers,
748
762
  body: JSON.stringify({ query, variables }),
@@ -45,6 +45,7 @@ var __importStar = (this && this.__importStar) || (function () {
45
45
  })();
46
46
  Object.defineProperty(exports, "__esModule", { value: true });
47
47
  exports.resolveObjktAlias = resolveObjktAlias;
48
+ const http_1 = require("./http");
48
49
  const logger = __importStar(require("../logger"));
49
50
  const user_agent_1 = require("./user-agent");
50
51
  const OBJKT_GRAPHQL = 'https://data.objkt.com/v3/graphql';
@@ -56,7 +57,7 @@ const OBJKT_GRAPHQL = 'https://data.objkt.com/v3/graphql';
56
57
  async function resolveObjktAlias(alias) {
57
58
  logger.debug(`[Objkt] Resolving alias "${alias}"`);
58
59
  const query = 'query ($path: String!) { fa(where: { path: { _eq: $path } }) { contract } }';
59
- const response = await fetch(OBJKT_GRAPHQL, {
60
+ const response = await (0, http_1.fetchWithTimeout)(OBJKT_GRAPHQL, {
60
61
  method: 'POST',
61
62
  headers: { 'Content-Type': 'application/json', 'User-Agent': user_agent_1.USER_AGENT },
62
63
  body: JSON.stringify({ query, variables: { path: alias } }),
@@ -59,6 +59,7 @@ var __importStar = (this && this.__importStar) || (function () {
59
59
  })();
60
60
  Object.defineProperty(exports, "__esModule", { value: true });
61
61
  exports.resolveOpenSeaCollection = resolveOpenSeaCollection;
62
+ const http_1 = require("./http");
62
63
  const logger = __importStar(require("../logger"));
63
64
  const user_agent_1 = require("./user-agent");
64
65
  /**
@@ -80,7 +81,7 @@ const FALLBACK_HINT = 'Paste a specific token URL (opensea.io/item/ethereum/{con
80
81
  */
81
82
  async function resolveOpenSeaCollection(slug) {
82
83
  logger.debug(`[OpenSea] Resolving collection slug "${slug}"`);
83
- const response = await fetch(`https://opensea.io/collection/${encodeURIComponent(slug)}`, {
84
+ const response = await (0, http_1.fetchWithTimeout)(`https://opensea.io/collection/${encodeURIComponent(slug)}`, {
84
85
  headers: { 'User-Agent': user_agent_1.USER_AGENT, Accept: 'text/html' },
85
86
  });
86
87
  if (!response.ok) {
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.isPlaylistSourceUrl = isPlaylistSourceUrl;
4
4
  exports.loadPlaylistSource = loadPlaylistSource;
5
5
  exports.resolvePlaySource = resolvePlaySource;
6
+ const http_1 = require("./http");
6
7
  const fs_1 = require("fs");
7
8
  // playlist-builder is still CommonJS; require keeps the interop simple.
8
9
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -39,7 +40,7 @@ async function loadPlaylistSource(source) {
39
40
  throw new Error('Playlist source is required');
40
41
  }
41
42
  if (isPlaylistSourceUrl(trimmedSource)) {
42
- const response = await fetch(trimmedSource);
43
+ const response = await (0, http_1.fetchWithTimeout)(trimmedSource);
43
44
  if (!response.ok) {
44
45
  throw new Error(`Failed to fetch playlist URL: ${response.status} ${response.statusText}`);
45
46
  }
@@ -57,6 +57,7 @@ var __importStar = (this && this.__importStar) || (function () {
57
57
  };
58
58
  })();
59
59
  Object.defineProperty(exports, "__esModule", { value: true });
60
+ exports.RasterUnreachableError = void 0;
60
61
  exports.resolveTokenToArtwork = resolveTokenToArtwork;
61
62
  exports.resolveSlugToArtwork = resolveSlugToArtwork;
62
63
  exports.resolveAddressToArtist = resolveAddressToArtist;
@@ -66,6 +67,14 @@ exports.formatSummaryLine = formatSummaryLine;
66
67
  const logger = __importStar(require("../logger"));
67
68
  const user_agent_1 = require("./user-agent");
68
69
  const DEFAULT_RASTER_GRAPHQL_URL = 'https://api.raster.art/graphql';
70
+ /**
71
+ * Hard ceiling on a single Raster round trip. Without it, a stalled TCP
72
+ * connect or unanswered request leaves `find` hanging with no output (#97) —
73
+ * the failure mode agents can least diagnose. 20 s is generous for a
74
+ * transcontinental GraphQL query and still turns "silent hang" into a fast,
75
+ * actionable error.
76
+ */
77
+ const RASTER_REQUEST_TIMEOUT_MS = 20_000;
69
78
  // CAIP-2 chain IDs for the chains the FF indexer supports.
70
79
  // Tezos mainnet's CAIP form uses the genesis block hash (NetXdQprcVkpaWU),
71
80
  // not the human-readable "tezos:mainnet" — Raster returns the hash form on
@@ -78,6 +87,19 @@ const INDEXER_CHAIN_TO_CAIP = {
78
87
  ethereum: 'eip155:1',
79
88
  tezos: 'tezos:NetXdQprcVkpaWU',
80
89
  };
90
+ /**
91
+ * Raster could not be reached at the network level (connect failure, broken
92
+ * IPv6 path, timeout). Distinct from API-level errors so callers that hold
93
+ * usable token coordinates can degrade gracefully instead of dying — Raster
94
+ * only enriches a find, it is not required to build a playable playlist.
95
+ */
96
+ class RasterUnreachableError extends Error {
97
+ constructor(message) {
98
+ super(message);
99
+ this.name = 'RasterUnreachableError';
100
+ }
101
+ }
102
+ exports.RasterUnreachableError = RasterUnreachableError;
81
103
  function caipToIndexerChain(caip) {
82
104
  return CAIP_TO_INDEXER_CHAIN[caip] ?? null;
83
105
  }
@@ -99,16 +121,46 @@ async function rasterQuery(query, variables) {
99
121
  if (apiKey) {
100
122
  headers['x-api-key'] = apiKey;
101
123
  }
102
- const response = await fetch(endpoint, {
103
- method: 'POST',
104
- headers,
105
- body: JSON.stringify({ query, variables }),
106
- });
124
+ let response;
125
+ try {
126
+ response = await fetch(endpoint, {
127
+ method: 'POST',
128
+ headers,
129
+ body: JSON.stringify({ query, variables }),
130
+ signal: AbortSignal.timeout(RASTER_REQUEST_TIMEOUT_MS),
131
+ });
132
+ }
133
+ catch (error) {
134
+ // Undici's "fetch failed" carries no endpoint and no cause in its
135
+ // message — name the host and the real failure so the error is
136
+ // actionable, and let callers detect network failure by type.
137
+ const cause = error.cause;
138
+ const detail = cause?.message ?? error.message;
139
+ throw new RasterUnreachableError(`Raster API unreachable (${endpoint}): ${detail}`);
140
+ }
107
141
  if (!response.ok) {
108
- const body = await response.text();
142
+ // Body text is best-effort context; the HTTP status alone is the error.
143
+ // A body read that rejects here must not mask the status — and an HTTP
144
+ // error is an API-level failure, deliberately NOT RasterUnreachableError.
145
+ const body = await response.text().catch(() => '');
109
146
  throw new Error(`Raster API ${response.status} ${response.statusText}: ${body.slice(0, 200)}`);
110
147
  }
111
- const payload = (await response.json());
148
+ // The network-error boundary extends through body consumption: a response
149
+ // whose stream stalls past the headers or terminates mid-body rejects
150
+ // HERE, not in fetch() above. Those must still surface as
151
+ // RasterUnreachableError or resolveCoords cannot perform its promised
152
+ // single-token fallback. (A 200 whose body cannot be read or parsed is
153
+ // unusable either way — falling back is right even for server-side
154
+ // garbage.)
155
+ let payload;
156
+ try {
157
+ payload = (await response.json());
158
+ }
159
+ catch (error) {
160
+ const cause = error.cause;
161
+ const detail = cause?.message ?? error.message;
162
+ throw new RasterUnreachableError(`Raster API unreachable (${endpoint}): response body failed: ${detail}`);
163
+ }
112
164
  if (payload.errors && payload.errors.length > 0) {
113
165
  throw new Error(`Raster API error: ${payload.errors.map((e) => e.message).join('; ')}`);
114
166
  }
@@ -37,6 +37,7 @@ var __importStar = (this && this.__importStar) || (function () {
37
37
  })();
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.sendSshAccessCommand = sendSshAccessCommand;
40
+ const http_1 = require("./http");
40
41
  const logger = __importStar(require("../logger"));
41
42
  const ff1_compatibility_1 = require("./ff1-compatibility");
42
43
  /**
@@ -106,7 +107,7 @@ async function sendSshAccessCommand({ enabled, deviceName, publicKey, ttlSeconds
106
107
  if (device.apiKey) {
107
108
  headers['API-KEY'] = device.apiKey;
108
109
  }
109
- const response = await fetch(apiUrl, {
110
+ const response = await (0, http_1.fetchWithTimeout)(apiUrl, {
110
111
  method: 'POST',
111
112
  headers,
112
113
  body: JSON.stringify(requestBody),
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.resolveVerseSeries = resolveVerseSeries;
4
+ const http_1 = require("./http");
4
5
  /**
5
6
  * Resolve a Verse series slug to representative Ethereum token coordinates.
6
7
  *
@@ -15,7 +16,7 @@ exports.resolveVerseSeries = resolveVerseSeries;
15
16
  */
16
17
  async function resolveVerseSeries(slug) {
17
18
  const url = `https://verse.works/series/${encodeURIComponent(slug)}`;
18
- const response = await fetch(url);
19
+ const response = await (0, http_1.fetchWithTimeout)(url);
19
20
  if (!response.ok) {
20
21
  throw new Error(`Verse series lookup failed for ${slug}: ${response.status}`);
21
22
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feralfile/cli",
3
- "version": "2.2.1",
3
+ "version": "2.2.2",
4
4
  "description": "CLI for building, signing, and playing DP-1 playlists of digital art on Feral File devices",
5
5
  "repository": {
6
6
  "type": "git",