@feralfile/cli 1.4.0 → 1.6.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feralfile/cli",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "CLI for building DP-1 playlists of digital art using AI (Claude, Grok, ChatGPT, Gemini)",
5
5
  "repository": {
6
6
  "type": "git",
@@ -37,6 +37,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.findCommand = void 0;
40
+ exports.buildRasterMediaItems = buildRasterMediaItems;
40
41
  exports.parseLimitOption = parseLimitOption;
41
42
  exports.decideActions = decideActions;
42
43
  const commander_1 = require("commander");
@@ -68,7 +69,7 @@ const DP1_MAX_ITEMS = 1024;
68
69
  const RASTER_PAGE_SIZE = 100;
69
70
  exports.findCommand = new commander_1.Command('find')
70
71
  .description('Find an artwork on the web and build a DP-1 playlist')
71
- .argument('<input>', 'URL (Objkt / fxhash / Art Blocks / OpenSea / SuperRare / Feral File / Neort / Verse), `ethereum:{contract}:{tokenId}`, `tezos:{contract}:{tokenId}`, or a wallet address')
72
+ .argument('<input>', 'URL (Objkt / fxhash / Art Blocks / OpenSea / SuperRare / Feral File / Neort / Verse / Raster), `ethereum:{contract}:{tokenId}`, `tezos:{contract}:{tokenId}`, or a wallet address')
72
73
  .option('-o, --output <path>', 'Save the playlist to this file (default: ./<slug>.json)')
73
74
  .option('-l, --limit <n>', 'Max tokens to include from the series (default: all)')
74
75
  .option('-p, --play', 'Play the playlist on an FF1 device after building')
@@ -83,7 +84,7 @@ exports.findCommand = new commander_1.Command('find')
83
84
  const parsed = (0, marketplace_url_1.parseFindInput)(input);
84
85
  if (!parsed) {
85
86
  console.error(chalk_1.default.red('Could not understand input.'));
86
- console.error(chalk_1.default.dim('Supported URLs: Objkt, fxhash, Art Blocks, OpenSea, SuperRare, Feral File, Neort, Verse. ' +
87
+ console.error(chalk_1.default.dim('Supported URLs: Objkt, fxhash, Art Blocks, OpenSea, SuperRare, Feral File, Neort, Verse, Raster. ' +
87
88
  'Or: `ethereum:{contract}:{tokenId}`, `tezos:{contract}:{tokenId}`, ' +
88
89
  'or a wallet address (`0x...` / `tz1.../tz2.../tz3...`).'));
89
90
  process.exit(1);
@@ -109,34 +110,64 @@ exports.findCommand = new commander_1.Command('find')
109
110
  }
110
111
  console.log();
111
112
  const userLimit = parseLimitOption(options.limit);
112
- const seriesTotal = target.kind === 'series' ? target.summary.tokenCount : 1;
113
113
  // Implicit cap: no --limit + series > 1024 → cap to DP-1 max with a
114
114
  // clear warning. Explicit `--limit > 1024` was already rejected by
115
115
  // parseLimitOption, so reaching here means either capped-by-default or
116
116
  // the user picked a value within spec.
117
117
  const limit = Math.min(userLimit, DP1_MAX_ITEMS);
118
- if (userLimit === Number.POSITIVE_INFINITY && seriesTotal > DP1_MAX_ITEMS) {
119
- console.log(chalk_1.default.yellow(`Series has ${seriesTotal} tokens; DP-1 caps playlists at ${DP1_MAX_ITEMS}. ` +
118
+ // Raster's GraphQL API exposes no series token counts, so enumerate
119
+ // tokens first (up to the effective limit) and confirm with the real
120
+ // number afterward. `hasMore` means the series continues past `limit`.
121
+ let tokens;
122
+ let seriesHasMore = false;
123
+ if (target.kind === 'series') {
124
+ const fetched = await fetchTokens(target.summary, limit);
125
+ tokens = fetched.tokens;
126
+ seriesHasMore = fetched.hasMore;
127
+ }
128
+ else {
129
+ tokens = [{ ...target.coords, mediaUrl: null, mediaType: null }];
130
+ }
131
+ if (tokens.length === 0) {
132
+ console.error(chalk_1.default.red('Series has no tokens on supported chains (Ethereum + Tezos mainnet).'));
133
+ process.exit(1);
134
+ }
135
+ if (userLimit === Number.POSITIVE_INFINITY && seriesHasMore) {
136
+ console.log(chalk_1.default.yellow(`Series has more than ${DP1_MAX_ITEMS} tokens; DP-1 caps playlists at ${DP1_MAX_ITEMS}. ` +
120
137
  `Building with the first ${DP1_MAX_ITEMS} — pass \`--limit N\` (≤ ${DP1_MAX_ITEMS}) for fewer.`));
121
138
  console.log();
122
139
  }
123
- const willInclude = Math.min(seriesTotal, limit);
124
- const shouldBuild = !!options.yes || !!options.output || (await confirmMakePlaylist(willInclude, seriesTotal));
140
+ const shouldBuild = !!options.yes ||
141
+ !!options.output ||
142
+ (await confirmMakePlaylist(tokens.length, seriesHasMore));
125
143
  if (!shouldBuild) {
126
144
  console.log(chalk_1.default.dim('Cancelled.'));
127
145
  return;
128
146
  }
129
- const tokens = target.kind === 'series' ? await fetchTokens(target.summary, limit) : [target.coords];
130
147
  console.log(chalk_1.default.dim(`Indexing ${tokens.length} token${tokens.length === 1 ? '' : 's'} via FF indexer...`));
131
148
  // Second positional arg on getNFTTokenInfoBatch is `duration` (DP-1 item
132
149
  // display seconds), not concurrency — concurrency is hardcoded inside.
133
150
  // Omit it for auto timing: video/audio items carry no duration and play
134
151
  // their natural length (DP-1 §4.1); static items get the config default.
135
- const items = await getNFTTokenInfoBatch(tokens.map((t) => ({
152
+ let items = await getNFTTokenInfoBatch(tokens.map((t) => ({
136
153
  chain: t.chain,
137
154
  contractAddress: t.contract,
138
155
  tokenId: t.tokenId,
139
156
  })));
157
+ // FF-indexer bypass for Raster-minted tokens. The indexer doesn't carry
158
+ // Raster's tokens, so it returns nothing — but Raster handed us each
159
+ // token's playable `media.contentUrl`. Build DP-1 items straight from
160
+ // that (off-chain provenance, same shape as the Neort path), so the
161
+ // series still builds into a signable, verifiable playlist instead of
162
+ // failing the way it did before. The indexer is tried first so tokens
163
+ // it *can* resolve keep their richer on-chain provenance.
164
+ if (!Array.isArray(items) || items.length === 0) {
165
+ const rasterItems = buildRasterMediaItems(tokens, target);
166
+ if (rasterItems.length > 0) {
167
+ console.log(chalk_1.default.dim(` FF indexer returned nothing; building ${rasterItems.length} item${rasterItems.length === 1 ? '' : 's'} directly from Raster media.`));
168
+ items = rasterItems;
169
+ }
170
+ }
140
171
  if (!Array.isArray(items) || items.length === 0) {
141
172
  console.error(chalk_1.default.red('No tokens could be indexed; cannot build playlist.'));
142
173
  console.error(chalk_1.default.dim(' The FF indexer may be under load when warming a series of previously-unseen tokens.'));
@@ -201,33 +232,42 @@ async function resolveTarget(parsed, skipPrompt) {
201
232
  const coords = await (0, verse_marketplace_1.resolveVerseSeries)(parsed.slug);
202
233
  return resolveCoords(coords);
203
234
  }
235
+ if (parsed.kind === 'raster-artwork') {
236
+ const summary = await (0, raster_client_1.resolveSlugToArtwork)(parsed.slug);
237
+ if (summary === null) {
238
+ throw new Error(`No artwork found on Raster for slug "${parsed.slug}". ` +
239
+ 'Check the URL — Raster artwork URLs are raster.art/artwork/{slug}.');
240
+ }
241
+ return { kind: 'series', summary };
242
+ }
204
243
  if (parsed.kind === 'address') {
205
- const artistId = await (0, raster_client_1.resolveAddressToArtist)(parsed.address);
206
- if (artistId === null) {
244
+ const artist = await (0, raster_client_1.resolveAddressToArtist)(parsed.address);
245
+ if (artist === null) {
207
246
  throw new Error(`No artist found on Raster for address ${parsed.address}. ` +
208
247
  'Raster indexes artists by their on-chain addresses; this address may not yet be claimed.');
209
248
  }
210
- const catalog = await (0, raster_client_1.listArtistArtworks)(artistId);
249
+ const catalog = await (0, raster_client_1.listArtistArtworks)(artist.id);
211
250
  if (catalog.length === 0) {
212
251
  throw new Error('Artist has no artworks indexed on Raster.');
213
252
  }
214
253
  const picked = await pickFromCatalog(catalog, skipPrompt);
215
- const summary = await (0, raster_client_1.getArtworkSummary)(picked.artworkId);
216
- return { kind: 'series', summary };
254
+ return {
255
+ kind: 'series',
256
+ summary: { artworkId: picked.artworkId, title: picked.title, artists: [artist] },
257
+ };
217
258
  }
218
259
  // `unsupported` is handled upstream; this branch keeps the type narrowing honest.
219
260
  throw new Error(`Unsupported parse result: ${parsed.kind}`);
220
261
  }
221
262
  /**
222
- * Resolve on-chain coords to a target. If Raster doesn't index this token
223
- * (404), fall back to single-token mode so we still build something playable.
263
+ * Resolve on-chain coords to a target. If Raster doesn't index this token,
264
+ * fall back to single-token mode so we still build something playable.
224
265
  */
225
266
  async function resolveCoords(coords) {
226
- const lookup = await (0, raster_client_1.resolveTokenToArtwork)(coords.chain, coords.contract, coords.tokenId);
227
- if (lookup === null) {
267
+ const summary = await (0, raster_client_1.resolveTokenToArtwork)(coords.chain, coords.contract, coords.tokenId);
268
+ if (summary === null) {
228
269
  return { kind: 'single', coords };
229
270
  }
230
- const summary = await (0, raster_client_1.getArtworkSummary)(lookup.artworkId);
231
271
  return { kind: 'series', summary };
232
272
  }
233
273
  /**
@@ -242,6 +282,32 @@ function playlistTitleFor(target, items) {
242
282
  }
243
283
  return items[0]?.title ?? `Token ${target.coords.tokenId}`;
244
284
  }
285
+ /**
286
+ * Build DP-1 items directly from Raster's per-token media, bypassing the FF
287
+ * indexer. Used as a fallback when the indexer resolves nothing — tokens
288
+ * without a Raster `mediaUrl` are dropped (nothing playable to point at).
289
+ *
290
+ * Item shape matches the Neort off-chain path (`buildUrlItem` →
291
+ * `provenance.offChainURI`), so the resulting playlist signs and verifies
292
+ * normally. Titles are "<Series> #<tokenId>" for series, falling back to the
293
+ * series/coords title for a single token.
294
+ */
295
+ function buildRasterMediaItems(tokens, target) {
296
+ const seriesTitle = target.kind === 'series' ? target.summary.title : undefined;
297
+ const items = [];
298
+ for (const t of tokens) {
299
+ if (!t.mediaUrl) {
300
+ continue;
301
+ }
302
+ const title = seriesTitle ? `${seriesTitle} #${t.tokenId}` : `Token ${t.tokenId}`;
303
+ // No explicit duration: auto timing (video/audio play their natural
304
+ // length). `mediaType` carries Raster's type so an extensionless IPFS
305
+ // contentUrl is still classified correctly — otherwise a video would be
306
+ // stamped a fixed duration and play as a still.
307
+ items.push(buildUrlItem(t.mediaUrl, undefined, { title, mimeType: t.mediaType ?? undefined }));
308
+ }
309
+ return items;
310
+ }
245
311
  function parseLimitOption(limitStr) {
246
312
  if (limitStr === undefined) {
247
313
  return Number.POSITIVE_INFINITY;
@@ -258,11 +324,17 @@ function parseLimitOption(limitStr) {
258
324
  }
259
325
  return n;
260
326
  }
327
+ /**
328
+ * Enumerate series tokens from Raster up to `limit`. `hasMore` reports
329
+ * whether the series continues past what was collected — the API exposes
330
+ * no total counts, so this is all the size information the flow gets.
331
+ */
261
332
  async function fetchTokens(summary, limit) {
262
333
  const collected = [];
263
334
  let cursor;
264
335
  let skipped = 0;
265
- while (collected.length < limit) {
336
+ let hasMore = false;
337
+ for (;;) {
266
338
  const page = await (0, raster_client_1.listArtworkTokens)(summary.artworkId, {
267
339
  cursor,
268
340
  pageSize: RASTER_PAGE_SIZE,
@@ -270,15 +342,22 @@ async function fetchTokens(summary, limit) {
270
342
  skipped += page.skippedUnsupported;
271
343
  for (const t of page.tokens) {
272
344
  if (collected.length >= limit) {
345
+ hasMore = true;
273
346
  break;
274
347
  }
275
348
  collected.push({
276
349
  chain: t.chain,
277
350
  contract: t.contractAddress,
278
351
  tokenId: t.tokenId,
352
+ mediaUrl: t.mediaUrl,
353
+ mediaType: t.mediaType,
279
354
  });
280
355
  }
281
- if (page.nextCursor === null) {
356
+ if (hasMore || page.nextCursor === null) {
357
+ break;
358
+ }
359
+ if (collected.length >= limit) {
360
+ hasMore = true;
282
361
  break;
283
362
  }
284
363
  cursor = page.nextCursor;
@@ -286,7 +365,7 @@ async function fetchTokens(summary, limit) {
286
365
  if (skipped > 0) {
287
366
  console.log(chalk_1.default.yellow(` Skipped ${skipped} token${skipped === 1 ? '' : 's'} on unsupported chains (FF indexer covers eth + tezos mainnet only).`));
288
367
  }
289
- return collected;
368
+ return { tokens: collected, hasMore };
290
369
  }
291
370
  async function pickFromCatalog(catalog, skipPrompt) {
292
371
  if (catalog.length === 1 || skipPrompt) {
@@ -294,8 +373,7 @@ async function pickFromCatalog(catalog, skipPrompt) {
294
373
  }
295
374
  console.log(chalk_1.default.cyan(`Found ${catalog.length} artworks for this artist:`));
296
375
  catalog.forEach((row, i) => {
297
- const count = `${row.tokenCount} token${row.tokenCount === 1 ? '' : 's'}`;
298
- console.log(chalk_1.default.dim(` ${i}: ${row.title} (${count})`));
376
+ console.log(chalk_1.default.dim(` ${i}: ${row.title}`));
299
377
  });
300
378
  console.log();
301
379
  const prompt = (0, prompt_1.createPrompt)();
@@ -310,9 +388,9 @@ async function pickFromCatalog(catalog, skipPrompt) {
310
388
  }
311
389
  return catalog[index];
312
390
  }
313
- async function confirmMakePlaylist(count, total) {
314
- const noun = total === 1 ? 'token' : 'tokens';
315
- const label = count < total ? `${count} of ${total} ${noun}` : `${count} ${noun}`;
391
+ async function confirmMakePlaylist(count, hasMore) {
392
+ const noun = count === 1 ? 'token' : 'tokens';
393
+ const label = hasMore ? `the first ${count} ${noun}` : `${count} ${noun}`;
316
394
  const prompt = (0, prompt_1.createPrompt)();
317
395
  const yes = await (0, prompt_1.promptYesNo)(prompt.ask, `Build playlist with ${label}?`, true);
318
396
  prompt.close();
@@ -488,7 +566,7 @@ async function runNeortFind(id, options) {
488
566
  console.log(chalk_1.default.cyan(`Neort — ${artistLabel} — ${art.title}`));
489
567
  console.log(chalk_1.default.dim(' (Off-chain platform; building a one-item playlist directly from Neort.)'));
490
568
  console.log();
491
- const shouldBuild = !!options.yes || !!options.output || (await confirmMakePlaylist(1, 1));
569
+ const shouldBuild = !!options.yes || !!options.output || (await confirmMakePlaylist(1, false));
492
570
  if (!shouldBuild) {
493
571
  console.log(chalk_1.default.dim('Cancelled.'));
494
572
  return;
@@ -26,6 +26,7 @@ exports.parseOpenSea = parseOpenSea;
26
26
  exports.parseSuperRare = parseSuperRare;
27
27
  exports.parseNeort = parseNeort;
28
28
  exports.parseVerse = parseVerse;
29
+ exports.parseRaster = parseRaster;
29
30
  exports.parseFeralFile = parseFeralFile;
30
31
  const ETH_ADDR = /^0x[a-fA-F0-9]{40}$/;
31
32
  const TEZOS_ADDR = /^(tz1|tz2|tz3)[1-9A-HJ-NP-Za-km-z]{33}$/;
@@ -101,6 +102,9 @@ function parseMarketplaceUrl(url) {
101
102
  if (host === 'verse.works' || host.endsWith('.verse.works')) {
102
103
  return parseVerse(url);
103
104
  }
105
+ if (host === 'raster.art' || host.endsWith('.raster.art')) {
106
+ return parseRaster(url);
107
+ }
104
108
  return null;
105
109
  }
106
110
  /**
@@ -382,6 +386,27 @@ function parseVerse(url) {
382
386
  '/items/ethereum/{contract}/{tokenId} or /series/{slug}.',
383
387
  };
384
388
  }
389
+ /**
390
+ * Raster URL forms (raster.art):
391
+ * raster.art/artwork/{slug} — artwork (series) detail page
392
+ *
393
+ * Unlike the other marketplaces, Raster is already the find flow's backend
394
+ * resolver — so a Raster URL resolves to an artwork by slug via
395
+ * `artworkBySlug` and (crucially) builds DP-1 items straight from Raster's
396
+ * own `media.contentUrl`, bypassing the FF indexer the way Neort does. This
397
+ * is what lets Raster-minted tokens (which the FF indexer doesn't carry)
398
+ * build a verifiable playlist without `--skip-verify`.
399
+ */
400
+ function parseRaster(url) {
401
+ const m = /^\/artwork\/([A-Za-z0-9][A-Za-z0-9_-]*)\/?$/.exec(url.pathname);
402
+ if (m) {
403
+ return { kind: 'raster-artwork', slug: m[1] };
404
+ }
405
+ return {
406
+ kind: 'unsupported',
407
+ reason: `Raster URL not recognized: ${url.pathname}. Expected /artwork/{slug}.`,
408
+ };
409
+ }
385
410
  /**
386
411
  * Feral File URL forms (source of truth: feralfile-client app-routing
387
412
  * + collection-routing modules):
@@ -536,6 +536,10 @@ function detectMimeType(url) {
536
536
  * (video/audio URLs play their natural length per DP-1 §4.1)
537
537
  * @param {Object} [options] - Optional configuration
538
538
  * @param {string} [options.title] - Optional item title override
539
+ * @param {string} [options.mimeType] - Source media type hint for timing
540
+ * (DP-1 §4.1). Needed when the URL has no file extension to detect from —
541
+ * e.g. extensionless IPFS gateway URLs, where without this hint a video
542
+ * would be misclassified as a static image and stamped a fixed duration.
539
543
  * @returns {Object} DP1 playlist item
540
544
  */
541
545
  function buildUrlItem(url, duration, options = {}) {
@@ -580,7 +584,7 @@ function buildUrlItem(url, duration, options = {}) {
580
584
  margin: 0,
581
585
  },
582
586
  };
583
- return applyItemTiming(item, { sourceUrl }, duration);
587
+ return applyItemTiming(item, { sourceUrl, mimeType: options.mimeType }, duration);
584
588
  }
585
589
  module.exports = {
586
590
  convertTokenToDP1Item,
@@ -1,9 +1,9 @@
1
1
  "use strict";
2
2
  /**
3
- * Raster API client (kit.raster.art)
3
+ * Raster API client (api.raster.art — public GraphQL API).
4
4
  *
5
- * Resolves on-chain coordinates and artist addresses into Raster `artwork_id`
6
- * (series) and `artist_id` (catalog) — the inputs the `ff-cli find` flow needs
5
+ * Resolves on-chain coordinates and artist addresses into Raster artworks
6
+ * (series) and artists (catalog) — the inputs the `ff-cli find` flow needs
7
7
  * before handing tokens to the FF GraphQL indexer for DP-1 conversion.
8
8
  *
9
9
  * This module is pure data: it does not construct DP-1 items, send anything
@@ -14,6 +14,14 @@
14
14
  * mainnet. Token lists are filtered here at the CAIP level and a
15
15
  * `skippedUnsupported` count is surfaced so callers can warn-skip without
16
16
  * walking every row themselves.
17
+ *
18
+ * API notes (v0.1.0, https://docs.api.raster.art/):
19
+ * - Auth: `x-api-key` header, sent when RASTER_API_KEY is set in the
20
+ * environment. The endpoint currently answers without a key; the docs
21
+ * declare one required, so the hook is wired in ahead of enforcement.
22
+ * - Not-found is a `null` query field, not an HTTP 404.
23
+ * - Connections expose no total counts — callers learn series size by
24
+ * paginating, so the find flow fetches before it prompts.
17
25
  */
18
26
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
19
27
  if (k2 === undefined) k2 = k;
@@ -49,130 +57,137 @@ var __importStar = (this && this.__importStar) || (function () {
49
57
  };
50
58
  })();
51
59
  Object.defineProperty(exports, "__esModule", { value: true });
52
- exports.RasterNotFoundError = void 0;
53
60
  exports.resolveTokenToArtwork = resolveTokenToArtwork;
54
- exports.getArtworkSummary = getArtworkSummary;
61
+ exports.resolveSlugToArtwork = resolveSlugToArtwork;
55
62
  exports.resolveAddressToArtist = resolveAddressToArtist;
56
63
  exports.listArtworkTokens = listArtworkTokens;
57
64
  exports.listArtistArtworks = listArtistArtworks;
58
65
  exports.formatSummaryLine = formatSummaryLine;
59
66
  const logger = __importStar(require("../logger"));
60
67
  const user_agent_1 = require("./user-agent");
61
- const RASTER_BASE_URL = 'https://kit.raster.art';
68
+ const DEFAULT_RASTER_GRAPHQL_URL = 'https://api.raster.art/graphql';
62
69
  // CAIP-2 chain IDs for the chains the FF indexer supports.
63
70
  // Tezos mainnet's CAIP form uses the genesis block hash (NetXdQprcVkpaWU),
64
71
  // not the human-readable "tezos:mainnet" — Raster returns the hash form on
65
- // token rows and rejects "tezos:mainnet" with HTTP 400.
72
+ // token rows and resolves nothing for "tezos:mainnet".
66
73
  const CAIP_TO_INDEXER_CHAIN = {
67
74
  'eip155:1': 'ethereum',
68
75
  'tezos:NetXdQprcVkpaWU': 'tezos',
69
76
  };
70
- function stripContractChainPrefix(raw) {
71
- const slash = raw.indexOf('/');
72
- return slash >= 0 ? raw.slice(slash + 1) : raw;
73
- }
77
+ const INDEXER_CHAIN_TO_CAIP = {
78
+ ethereum: 'eip155:1',
79
+ tezos: 'tezos:NetXdQprcVkpaWU',
80
+ };
74
81
  function caipToIndexerChain(caip) {
75
82
  return CAIP_TO_INDEXER_CHAIN[caip] ?? null;
76
83
  }
77
84
  /**
78
- * Thrown when Raster returns 404 for a requested path. Used as a soft signal
79
- * by callers that have a fallback path (e.g. `find` falls back to a
80
- * single-token playlist when Raster doesn't index the artwork).
85
+ * POST one GraphQL operation to Raster and return `data`.
86
+ *
87
+ * Throws on HTTP errors and on GraphQL `errors` entries. "Not found" never
88
+ * lands here — Raster models it as a null query field with no error, so
89
+ * callers check their own field for null.
81
90
  */
82
- class RasterNotFoundError extends Error {
83
- notFound = true;
84
- constructor(path) {
85
- super(`Raster: not found (${path})`);
86
- this.name = 'RasterNotFoundError';
91
+ async function rasterQuery(query, variables) {
92
+ const endpoint = process.env.RASTER_API_URL ?? DEFAULT_RASTER_GRAPHQL_URL;
93
+ logger.debug(`[Raster] POST ${endpoint} ${JSON.stringify(variables)}`);
94
+ const headers = {
95
+ 'Content-Type': 'application/json',
96
+ 'User-Agent': user_agent_1.USER_AGENT,
97
+ };
98
+ const apiKey = process.env.RASTER_API_KEY;
99
+ if (apiKey) {
100
+ headers['x-api-key'] = apiKey;
87
101
  }
88
- }
89
- exports.RasterNotFoundError = RasterNotFoundError;
90
- async function rasterFetch(path) {
91
- const url = `${RASTER_BASE_URL}${path}`;
92
- logger.debug(`[Raster] GET ${url}`);
93
- const response = await fetch(url, {
94
- headers: { 'User-Agent': user_agent_1.USER_AGENT, Accept: 'application/json' },
102
+ const response = await fetch(endpoint, {
103
+ method: 'POST',
104
+ headers,
105
+ body: JSON.stringify({ query, variables }),
95
106
  });
96
- if (response.status === 404) {
97
- throw new RasterNotFoundError(path);
98
- }
99
107
  if (!response.ok) {
100
108
  const body = await response.text();
101
- throw new Error(`Raster API ${response.status} ${response.statusText} for ${path}: ${body.slice(0, 200)}`);
109
+ throw new Error(`Raster API ${response.status} ${response.statusText}: ${body.slice(0, 200)}`);
102
110
  }
103
- return (await response.json());
111
+ const payload = (await response.json());
112
+ if (payload.errors && payload.errors.length > 0) {
113
+ throw new Error(`Raster API error: ${payload.errors.map((e) => e.message).join('; ')}`);
114
+ }
115
+ if (payload.data === undefined) {
116
+ throw new Error('Raster API returned no data.');
117
+ }
118
+ return payload.data;
119
+ }
120
+ function toRasterArtist(raw) {
121
+ return { id: raw.id, name: raw.name ?? 'Unknown artist', slug: raw.slug };
104
122
  }
105
123
  /**
106
- * Resolve a token's on-chain coordinates to its Raster artwork (series) id.
124
+ * Resolve a token's on-chain coordinates to its Raster artwork (series).
107
125
  *
108
- * Accepts either a CAIP chain id (`eip155:1`) or human slug (`ethereum`) —
109
- * Raster handles both. Returns `null` when Raster doesn't index this token
110
- * (HTTP 404); callers can fall back to a single-token playlist in that case.
111
- * Other Raster errors throw normally.
126
+ * One round trip: `tokenByRef` returns the artwork with artists inline, so
127
+ * this yields the full summary directly. Returns `null` when Raster doesn't
128
+ * index this token; callers fall back to a single-token playlist in that
129
+ * case. Tokens occasionally belong to multiple artworks; the first one is
130
+ * Raster's primary association.
112
131
  */
113
132
  async function resolveTokenToArtwork(chain, contract, tokenId) {
114
- const path = `/token/${encodeURIComponent(chain)}` +
115
- `/${encodeURIComponent(contract)}` +
116
- `/${encodeURIComponent(tokenId)}`;
117
- try {
118
- const data = await rasterFetch(path);
119
- return {
120
- artworkId: data.artwork.id,
121
- artworkTitle: data.artwork.title,
122
- };
123
- }
124
- catch (error) {
125
- if (error instanceof RasterNotFoundError) {
126
- return null;
127
- }
128
- throw error;
133
+ const data = await rasterQuery(`query ResolveToken($ref: TokenRefInput!) {
134
+ tokenByRef(ref: $ref) {
135
+ artworks { id title artists { id name slug } }
136
+ }
137
+ }`, {
138
+ ref: {
139
+ chainId: INDEXER_CHAIN_TO_CAIP[chain],
140
+ contractAddress: contract,
141
+ tokenId,
142
+ },
143
+ });
144
+ const artwork = data.tokenByRef?.artworks?.[0];
145
+ if (!artwork) {
146
+ return null;
129
147
  }
148
+ return {
149
+ artworkId: artwork.id,
150
+ title: artwork.title,
151
+ artists: (artwork.artists ?? []).map(toRasterArtist),
152
+ };
130
153
  }
131
154
  /**
132
- * Get the full Raster artwork summary suitable for the one-line resolve
133
- * message ("Snowfro — send/receive — 8977 tokens") and downstream UX.
155
+ * Resolve a `raster.art/artwork/{slug}` URL to its artwork (series) summary.
156
+ *
157
+ * One round trip via `artworkBySlug`. Returns `null` when the slug is
158
+ * unknown (Raster models not-found as a null query field, not an HTTP 404).
134
159
  */
135
- async function getArtworkSummary(artworkId) {
136
- const data = await rasterFetch(`/artwork/${artworkId}`);
160
+ async function resolveSlugToArtwork(slug) {
161
+ const data = await rasterQuery(`query ResolveSlug($slug: String!) {
162
+ artworkBySlug(slug: $slug) {
163
+ id
164
+ title
165
+ artists { id name slug }
166
+ }
167
+ }`, { slug });
168
+ const artwork = data.artworkBySlug;
169
+ if (!artwork) {
170
+ return null;
171
+ }
137
172
  return {
138
- artworkId: data.id,
139
- title: data.title,
140
- artists: data.artists ?? [],
141
- tokenCount: data.token_count,
142
- editionSize: data.edition_size,
143
- isEdition: data.is_edition,
144
- hasOneOfOneTokens: data.has_one_of_one_tokens,
145
- platforms: data.platforms ?? [],
146
- contractAddresses: data.contract_addresses ?? [],
173
+ artworkId: artwork.id,
174
+ title: artwork.title,
175
+ artists: (artwork.artists ?? []).map(toRasterArtist),
147
176
  };
148
177
  }
149
178
  /**
150
- * Resolve an artist's wallet address to a Raster artist id via `/search`.
179
+ * Resolve a wallet address to the Raster artist who claims it.
151
180
  *
152
- * `/artist/{id}` does not accept raw addresses (404), so we bridge through
153
- * the multi-search endpoint and pick the artist hit whose `addresses` list
154
- * contains the input. Returns `null` when no artist matches.
181
+ * The `address` query carries artist associations directly (the old REST
182
+ * API needed a `/search` bridge). Returns `null` when no artist claims the
183
+ * address. Checksummed Ethereum input is normalized server-side.
155
184
  */
156
185
  async function resolveAddressToArtist(address) {
157
- const normalized = address.trim().toLowerCase();
158
- const data = await rasterFetch(`/search?query=${encodeURIComponent(address)}`);
159
- for (const result of data.results ?? []) {
160
- for (const hit of result.hits ?? []) {
161
- const doc = hit.document;
162
- if (doc.data_type !== 'artist') {
163
- continue;
164
- }
165
- const addrs = (doc.addresses ?? []).map((a) => a.toLowerCase());
166
- if (!addrs.includes(normalized)) {
167
- continue;
168
- }
169
- const parsed = Number.parseInt(doc.data_pk, 10);
170
- if (Number.isFinite(parsed)) {
171
- return parsed;
172
- }
173
- }
174
- }
175
- return null;
186
+ const data = await rasterQuery(`query ResolveAddress($address: String!) {
187
+ address(address: $address) { artists { id name slug } }
188
+ }`, { address: address.trim() });
189
+ const artist = data.address?.artists?.[0];
190
+ return artist ? toRasterArtist(artist) : null;
176
191
  }
177
192
  /**
178
193
  * List tokens in a Raster artwork (series), one page at a time.
@@ -183,70 +198,83 @@ async function resolveAddressToArtist(address) {
183
198
  * warn-skip line instead of one per token.
184
199
  *
185
200
  * Pagination: pass the returned `nextCursor` back as `options.cursor`.
186
- * `nextCursor` is `null` on the final page. Raster signals end-of-stream by
187
- * returning `cursor === total_count`; that is normalized to `null` here.
201
+ * `nextCursor` is `null` on the final page. The API exposes no total count;
202
+ * the only way to learn a series' size is to walk it.
188
203
  */
189
204
  async function listArtworkTokens(artworkId, options = {}) {
190
- const params = new URLSearchParams();
191
- if (options.cursor !== undefined) {
192
- params.set('cursor', String(options.cursor));
193
- }
194
- if (options.pageSize !== undefined) {
195
- params.set('page_size', String(options.pageSize));
205
+ const data = await rasterQuery(`query ArtworkTokens($id: ID!, $first: Int, $after: String) {
206
+ artwork(id: $id) {
207
+ tokens(first: $first, after: $after) {
208
+ nodes { chainId contractAddress tokenId media { contentUrl contentType previewType } }
209
+ pageInfo { hasNextPage endCursor }
210
+ }
211
+ }
212
+ }`, { id: artworkId, first: options.pageSize ?? null, after: options.cursor ?? null });
213
+ if (!data.artwork) {
214
+ throw new Error(`Raster: artwork ${artworkId} not found.`);
196
215
  }
197
- const qs = params.toString();
198
- const path = `/artwork/${artworkId}/tokens${qs ? `?${qs}` : ''}`;
199
- const data = await rasterFetch(path);
200
216
  const tokens = [];
201
217
  let skippedUnsupported = 0;
202
- for (const raw of data.tokens ?? []) {
203
- const chain = caipToIndexerChain(raw.chain_id);
204
- if (!chain) {
218
+ for (const raw of data.artwork.tokens.nodes) {
219
+ const chain = caipToIndexerChain(raw.chainId);
220
+ if (!chain || !raw.contractAddress) {
205
221
  skippedUnsupported += 1;
206
222
  continue;
207
223
  }
208
224
  tokens.push({
209
- caipChain: raw.chain_id,
210
225
  chain,
211
- contractAddress: stripContractChainPrefix(raw.contract_address),
212
- tokenId: raw.token_id,
226
+ contractAddress: raw.contractAddress,
227
+ tokenId: raw.tokenId,
228
+ mediaUrl: raw.media?.contentUrl ?? null,
229
+ // Prefer a real MIME (`contentType`) when Raster populates it; today it
230
+ // is usually empty, so fall back to the category-prefixed `previewType`.
231
+ mediaType: raw.media?.contentType || raw.media?.previewType || null,
213
232
  });
214
233
  }
215
- const rawCursor = data.cursor;
216
- const nextCursor = rawCursor === null || rawCursor >= data.total_count ? null : rawCursor;
234
+ const { hasNextPage, endCursor } = data.artwork.tokens.pageInfo;
217
235
  return {
218
236
  tokens,
219
- totalCount: data.total_count,
220
- nextCursor,
237
+ nextCursor: hasNextPage && endCursor ? endCursor : null,
221
238
  skippedUnsupported,
222
239
  };
223
240
  }
224
241
  /**
225
- * List an artist's full catalog as light artwork rows (id, title, token count,
226
- * edition flag, mint date). Use {@link getArtworkSummary} when full details
227
- * are needed for a specific row.
242
+ * List an artist's full catalog as light artwork rows (id + title — the
243
+ * API exposes no per-artwork token counts or mint dates yet).
228
244
  *
229
- * The endpoint returns the full catalog in a single response; no cursor is
230
- * exposed by Raster for this path.
245
+ * Walks the artist's `artworks` connection to the end so the caller sees
246
+ * the complete catalog, matching the old single-response REST behavior.
231
247
  */
232
248
  async function listArtistArtworks(artistId) {
233
- const data = await rasterFetch(`/artist/${artistId}/artworks`);
234
- return (data.artworks ?? []).map((row) => ({
235
- artworkId: row.id,
236
- title: row.title,
237
- tokenCount: row.num_tokens ?? row.num_pieces ?? 0,
238
- isEdition: row.is_edition,
239
- mintDate: row.mint_date ?? null,
240
- }));
249
+ const rows = [];
250
+ let cursor = null;
251
+ do {
252
+ const data = await rasterQuery(`query ArtistArtworks($id: ID!, $after: String) {
253
+ artist(id: $id) {
254
+ artworks(first: 100, after: $after) {
255
+ nodes { id title }
256
+ pageInfo { hasNextPage endCursor }
257
+ }
258
+ }
259
+ }`, { id: artistId, after: cursor });
260
+ if (!data.artist) {
261
+ throw new Error(`Raster: artist ${artistId} not found.`);
262
+ }
263
+ for (const node of data.artist.artworks.nodes) {
264
+ rows.push({ artworkId: node.id, title: node.title });
265
+ }
266
+ const { hasNextPage, endCursor } = data.artist.artworks.pageInfo;
267
+ cursor = hasNextPage && endCursor ? endCursor : null;
268
+ } while (cursor !== null);
269
+ return rows;
241
270
  }
242
271
  /**
243
272
  * Format a one-line summary for the resolved artwork.
244
273
  *
245
274
  * @example
246
- * formatSummaryLine(summary) // "Snowfro — send/receive — 8977 tokens"
275
+ * formatSummaryLine(summary) // "Snowfro — send/receive"
247
276
  */
248
277
  function formatSummaryLine(summary) {
249
278
  const artistNames = summary.artists.map((a) => a.name).join(', ') || 'Unknown artist';
250
- const tokenWord = summary.tokenCount === 1 ? 'token' : 'tokens';
251
- return `${artistNames} — ${summary.title} — ${summary.tokenCount} ${tokenWord}`;
279
+ return `${artistNames} — ${summary.title}`;
252
280
  }
@@ -116,6 +116,19 @@ Environment variable alternative:
116
116
  FEED_BASE_URLS=https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1,https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1
117
117
  ```
118
118
 
119
+ ## find (Raster API)
120
+
121
+ `ff-cli find` resolves tokens, series, and artist addresses through Raster's public GraphQL API ([docs.api.raster.art](https://docs.api.raster.art/)). No configuration is required; two environment variables adjust the integration:
122
+
123
+ ```env
124
+ # API key sent as the x-api-key header. The API currently answers without
125
+ # one, but Raster's docs declare a key required — set this once enforced.
126
+ RASTER_API_KEY=rk_your_key_here
127
+
128
+ # Override the GraphQL endpoint (default: https://api.raster.art/graphql).
129
+ RASTER_API_URL=https://api.raster.art/graphql
130
+ ```
131
+
119
132
  ## ff1Devices
120
133
 
121
134
  Configure devices you want to play content on.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feralfile/cli",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "CLI for building DP-1 playlists of digital art using AI (Claude, Grok, ChatGPT, Gemini)",
5
5
  "repository": {
6
6
  "type": "git",