@feralfile/cli 2.2.0 → 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/README.md CHANGED
@@ -119,7 +119,6 @@ npm run dev -- setup
119
119
  npm run dev -- find https://objkt.com/tokens/hicetnunc/111068 --play
120
120
  npm run dev -- play "https://example.com/video.mp4" --skip-verify
121
121
  ```
122
-
123
122
  ## Documentation
124
123
 
125
124
  - Getting started and usage: `./docs/README.md`
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.0",
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",
@@ -59,7 +59,7 @@
59
59
  "license": "MIT",
60
60
  "dependencies": {
61
61
  "@napi-rs/keyring": "1.3.0",
62
- "@feralfile/source-resolver": "git+https://github.com/feral-file/ff-source-resolver.git#54be943014c8cf7860114510257cdd713bbf4e79",
62
+ "@feralfile/source-resolver": "^1.0.0",
63
63
  "axios": "^1.6.2",
64
64
  "bonjour-service": "^1.3.0",
65
65
  "chalk": "^4.1.2",
@@ -207,8 +207,10 @@ deviceCommand
207
207
  // existingIndex === -1 (no confirmed match, e.g. manual IP → .local migration),
208
208
  // a same-name entry is the upsertDevice case-3 migration path — blocking it would
209
209
  // prevent the user from retaining their existing device name during host migration.
210
+ // Case-insensitive: lookups everywhere else ignore case, so "Kitchen"
211
+ // alongside "kitchen" would make one of them unreachable by name.
210
212
  const nameConflict = existingIndex !== -1
211
- ? existingDevices.find((d, i) => d.name === deviceName && i !== existingIndex)
213
+ ? existingDevices.find((d, i) => d.name?.toLowerCase() === deviceName.toLowerCase() && i !== existingIndex)
212
214
  : undefined;
213
215
  if (nameConflict) {
214
216
  if (options.name) {
@@ -221,7 +223,7 @@ deviceCommand
221
223
  const retryAnswer = await ask('Device name: ');
222
224
  deviceName = retryAnswer || 'ff1';
223
225
  const retryConflict = existingIndex !== -1
224
- ? existingDevices.find((d, i) => d.name === deviceName && i !== existingIndex)
226
+ ? existingDevices.find((d, i) => d.name?.toLowerCase() === deviceName.toLowerCase() && i !== existingIndex)
225
227
  : undefined;
226
228
  if (retryConflict) {
227
229
  console.error(chalk_1.default.red(`\nName "${deviceName}" is also taken. No changes made.`));
@@ -255,21 +257,7 @@ deviceCommand
255
257
  }
256
258
  const config = await (0, config_files_1.readConfigFile)(configPath);
257
259
  const existingDevices = config.ff1Devices?.devices || [];
258
- // Match by name (case-insensitive) or by host URL so unnamed legacy/manual
259
- // entries (stored without a name field) can still be targeted and removed.
260
- const normalizedArg = name.toLowerCase();
261
- let normalizedArgHost = '';
262
- try {
263
- normalizedArgHost = (0, device_normalize_1.normalizeDeviceHost)(name).toLowerCase();
264
- }
265
- catch {
266
- // not a valid URL — host matching will not apply
267
- }
268
- const deviceIndex = existingDevices.findIndex((d) => (d.name && d.name.toLowerCase() === normalizedArg) ||
269
- (d.host && d.host.toLowerCase() === normalizedArg) ||
270
- (normalizedArgHost &&
271
- d.host &&
272
- (0, device_normalize_1.normalizeDeviceHost)(d.host).toLowerCase() === normalizedArgHost));
260
+ const deviceIndex = (0, device_lookup_1.findConfiguredDeviceIndex)(existingDevices, name);
273
261
  if (deviceIndex === -1) {
274
262
  console.error(chalk_1.default.red(`\nDevice "${name}" not found`));
275
263
  if (existingDevices.length > 0) {
@@ -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 } }),
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.promoteDeviceToDefault = promoteDeviceToDefault;
4
- const device_normalize_1 = require("./device-normalize");
4
+ const device_lookup_1 = require("./device-lookup");
5
5
  /**
6
6
  * Move the named device to index 0 so it becomes the implicit default.
7
7
  *
@@ -11,19 +11,7 @@ const device_normalize_1 = require("./device-normalize");
11
11
  * @throws {Error} When no device matches the identifier
12
12
  */
13
13
  function promoteDeviceToDefault(devices, identifier) {
14
- const normalizedArg = identifier.toLowerCase();
15
- let normalizedArgHost = '';
16
- try {
17
- normalizedArgHost = (0, device_normalize_1.normalizeDeviceHost)(identifier).toLowerCase();
18
- }
19
- catch {
20
- // not a valid URL — host matching will not apply
21
- }
22
- const index = devices.findIndex((d) => (d.name && d.name.toLowerCase() === normalizedArg) ||
23
- (d.host && d.host.toLowerCase() === normalizedArg) ||
24
- (normalizedArgHost &&
25
- d.host &&
26
- (0, device_normalize_1.normalizeDeviceHost)(d.host).toLowerCase() === normalizedArgHost));
14
+ const index = (0, device_lookup_1.findConfiguredDeviceIndex)(devices, identifier);
27
15
  if (index === -1) {
28
16
  throw new Error(`Device "${identifier}" not found`);
29
17
  }
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.findExistingDeviceEntry = findExistingDeviceEntry;
4
+ exports.findConfiguredDeviceIndex = findConfiguredDeviceIndex;
5
+ const device_normalize_1 = require("./device-normalize");
4
6
  /**
5
7
  * Find an existing configured device that corresponds to a newly discovered host.
6
8
  *
@@ -106,3 +108,29 @@ function findExistingDeviceEntry(existingDevices, newHost, discoveredName, disco
106
108
  }
107
109
  return undefined;
108
110
  }
111
+ /**
112
+ * Find the index of a configured device by a user-supplied identifier.
113
+ *
114
+ * Matches by friendly name (case-insensitive) or by host URL — raw or
115
+ * normalized — so unnamed legacy/manual entries (stored without a name field)
116
+ * can still be targeted. Shared by `device remove`, `device default`,
117
+ * `device rename`, and runtime `-d` resolution so every surface answers to
118
+ * the same identifier the same way.
119
+ *
120
+ * Returns -1 when nothing matches.
121
+ */
122
+ function findConfiguredDeviceIndex(devices, identifier) {
123
+ const normalizedArg = identifier.toLowerCase();
124
+ let normalizedArgHost = '';
125
+ try {
126
+ normalizedArgHost = (0, device_normalize_1.normalizeDeviceHost)(identifier).toLowerCase();
127
+ }
128
+ catch {
129
+ // not a valid URL — host matching will not apply
130
+ }
131
+ return devices.findIndex((d) => (d.name && d.name.toLowerCase() === normalizedArg) ||
132
+ (d.host && d.host.toLowerCase() === normalizedArg) ||
133
+ (normalizedArgHost &&
134
+ d.host &&
135
+ (0, device_normalize_1.normalizeDeviceHost)(d.host).toLowerCase() === normalizedArgHost));
136
+ }
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.renameDevice = renameDevice;
4
- const device_normalize_1 = require("./device-normalize");
4
+ const device_lookup_1 = require("./device-lookup");
5
5
  /**
6
6
  * Rename a configured device without touching host, apiKey, topicID, or list
7
7
  * position — a pure config edit that needs no discovery or network access
@@ -20,19 +20,7 @@ function renameDevice(devices, identifier, newName) {
20
20
  if (!trimmedName) {
21
21
  throw new Error('New device name must not be empty');
22
22
  }
23
- const normalizedArg = identifier.toLowerCase();
24
- let normalizedArgHost = '';
25
- try {
26
- normalizedArgHost = (0, device_normalize_1.normalizeDeviceHost)(identifier).toLowerCase();
27
- }
28
- catch {
29
- // not a valid URL — host matching will not apply
30
- }
31
- const index = devices.findIndex((d) => (d.name && d.name.toLowerCase() === normalizedArg) ||
32
- (d.host && d.host.toLowerCase() === normalizedArg) ||
33
- (normalizedArgHost &&
34
- d.host &&
35
- (0, device_normalize_1.normalizeDeviceHost)(d.host).toLowerCase() === normalizedArgHost));
23
+ const index = (0, device_lookup_1.findConfiguredDeviceIndex)(devices, identifier);
36
24
  if (index === -1) {
37
25
  throw new Error(`Device "${identifier}" not found`);
38
26
  }
@@ -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,7 +38,9 @@ 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");
43
+ const device_lookup_1 = require("./device-lookup");
42
44
  const logger = __importStar(require("../logger"));
43
45
  const FF1_COMMAND_POLICIES = {
44
46
  displayPlaylist: {
@@ -51,7 +53,8 @@ const FF1_COMMAND_POLICIES = {
51
53
  /**
52
54
  * Load and validate the configured FF1 device selected by name.
53
55
  *
54
- * @param {string} [deviceName] - Optional device name, exact match required
56
+ * @param {string} [deviceName] - Optional device name (case-insensitive) or host URL,
57
+ * resolved with the same matching rules as `device remove`/`default`/`rename`
55
58
  * @param {Object} [options] - Optional dependency overrides
56
59
  * @param {Function} [options.getFF1DeviceConfigFn] - Optional config loader override
57
60
  * @returns {FF1DeviceSelectionResult} Selected device or reason for failure
@@ -70,7 +73,8 @@ function resolveConfiguredDevice(deviceName, options = {}) {
70
73
  }
71
74
  let device = deviceConfig.devices[0];
72
75
  if (deviceName) {
73
- device = deviceConfig.devices.find((item) => item.name === deviceName);
76
+ const index = (0, device_lookup_1.findConfiguredDeviceIndex)(deviceConfig.devices, deviceName);
77
+ device = index === -1 ? undefined : deviceConfig.devices[index];
74
78
  if (!device) {
75
79
  const availableNames = deviceConfig.devices
76
80
  .map((item) => item.name)
@@ -110,7 +114,9 @@ function resolveConfiguredDevice(deviceName, options = {}) {
110
114
  * const result = await assertFF1CommandCompatibility(device, 'displayPlaylist');
111
115
  */
112
116
  async function assertFF1CommandCompatibility(device, command, options = {}) {
113
- 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 ?? {}));
114
120
  const policy = getCommandPolicy(command);
115
121
  const versionResult = await detectFF1VersionSafely(device.host, buildVersionHeaders(device), fetchFn);
116
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
  }
@@ -151,7 +151,7 @@ Setup and `device add` both preserve existing devices. Adding a device with the
151
151
  Selection rules when sending:
152
152
 
153
153
  - If you omit `-d`, the first configured device is used.
154
- - If you pass `-d <name>`, the CLI matches the device by `name` (exact match). If not found, you’ll see an error listing available devices.
154
+ - If you pass `-d <name>`, the CLI matches the device by `name` (case-insensitive) or by host URL — the same rules as `device remove`/`default`/`rename`, so unnamed entries can be targeted by host. If not found, you’ll see an error listing available devices.
155
155
 
156
156
  Device cast contract:
157
157
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feralfile/cli",
3
- "version": "2.2.0",
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",
@@ -59,7 +59,7 @@
59
59
  "license": "MIT",
60
60
  "dependencies": {
61
61
  "@napi-rs/keyring": "1.3.0",
62
- "@feralfile/source-resolver": "git+https://github.com/feral-file/ff-source-resolver.git#54be943014c8cf7860114510257cdd713bbf4e79",
62
+ "@feralfile/source-resolver": "^1.0.0",
63
63
  "axios": "^1.6.2",
64
64
  "bonjour-service": "^1.3.0",
65
65
  "chalk": "^4.1.2",