@feralfile/cli 1.3.1 → 1.5.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.
@@ -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,112 @@ 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;
55
61
  exports.resolveAddressToArtist = resolveAddressToArtist;
56
62
  exports.listArtworkTokens = listArtworkTokens;
57
63
  exports.listArtistArtworks = listArtistArtworks;
58
64
  exports.formatSummaryLine = formatSummaryLine;
59
65
  const logger = __importStar(require("../logger"));
60
66
  const user_agent_1 = require("./user-agent");
61
- const RASTER_BASE_URL = 'https://kit.raster.art';
67
+ const DEFAULT_RASTER_GRAPHQL_URL = 'https://api.raster.art/graphql';
62
68
  // CAIP-2 chain IDs for the chains the FF indexer supports.
63
69
  // Tezos mainnet's CAIP form uses the genesis block hash (NetXdQprcVkpaWU),
64
70
  // not the human-readable "tezos:mainnet" — Raster returns the hash form on
65
- // token rows and rejects "tezos:mainnet" with HTTP 400.
71
+ // token rows and resolves nothing for "tezos:mainnet".
66
72
  const CAIP_TO_INDEXER_CHAIN = {
67
73
  'eip155:1': 'ethereum',
68
74
  'tezos:NetXdQprcVkpaWU': 'tezos',
69
75
  };
70
- function stripContractChainPrefix(raw) {
71
- const slash = raw.indexOf('/');
72
- return slash >= 0 ? raw.slice(slash + 1) : raw;
73
- }
76
+ const INDEXER_CHAIN_TO_CAIP = {
77
+ ethereum: 'eip155:1',
78
+ tezos: 'tezos:NetXdQprcVkpaWU',
79
+ };
74
80
  function caipToIndexerChain(caip) {
75
81
  return CAIP_TO_INDEXER_CHAIN[caip] ?? null;
76
82
  }
77
83
  /**
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).
84
+ * POST one GraphQL operation to Raster and return `data`.
85
+ *
86
+ * Throws on HTTP errors and on GraphQL `errors` entries. "Not found" never
87
+ * lands here — Raster models it as a null query field with no error, so
88
+ * callers check their own field for null.
81
89
  */
82
- class RasterNotFoundError extends Error {
83
- notFound = true;
84
- constructor(path) {
85
- super(`Raster: not found (${path})`);
86
- this.name = 'RasterNotFoundError';
90
+ async function rasterQuery(query, variables) {
91
+ const endpoint = process.env.RASTER_API_URL ?? DEFAULT_RASTER_GRAPHQL_URL;
92
+ logger.debug(`[Raster] POST ${endpoint} ${JSON.stringify(variables)}`);
93
+ const headers = {
94
+ 'Content-Type': 'application/json',
95
+ 'User-Agent': user_agent_1.USER_AGENT,
96
+ };
97
+ const apiKey = process.env.RASTER_API_KEY;
98
+ if (apiKey) {
99
+ headers['x-api-key'] = apiKey;
87
100
  }
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' },
101
+ const response = await fetch(endpoint, {
102
+ method: 'POST',
103
+ headers,
104
+ body: JSON.stringify({ query, variables }),
95
105
  });
96
- if (response.status === 404) {
97
- throw new RasterNotFoundError(path);
98
- }
99
106
  if (!response.ok) {
100
107
  const body = await response.text();
101
- throw new Error(`Raster API ${response.status} ${response.statusText} for ${path}: ${body.slice(0, 200)}`);
108
+ throw new Error(`Raster API ${response.status} ${response.statusText}: ${body.slice(0, 200)}`);
109
+ }
110
+ const payload = (await response.json());
111
+ if (payload.errors && payload.errors.length > 0) {
112
+ throw new Error(`Raster API error: ${payload.errors.map((e) => e.message).join('; ')}`);
113
+ }
114
+ if (payload.data === undefined) {
115
+ throw new Error('Raster API returned no data.');
102
116
  }
103
- return (await response.json());
117
+ return payload.data;
118
+ }
119
+ function toRasterArtist(raw) {
120
+ return { id: raw.id, name: raw.name ?? 'Unknown artist', slug: raw.slug };
104
121
  }
105
122
  /**
106
- * Resolve a token's on-chain coordinates to its Raster artwork (series) id.
123
+ * Resolve a token's on-chain coordinates to its Raster artwork (series).
107
124
  *
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.
125
+ * One round trip: `tokenByRef` returns the artwork with artists inline, so
126
+ * this yields the full summary directly. Returns `null` when Raster doesn't
127
+ * index this token; callers fall back to a single-token playlist in that
128
+ * case. Tokens occasionally belong to multiple artworks; the first one is
129
+ * Raster's primary association.
112
130
  */
113
131
  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;
132
+ const data = await rasterQuery(`query ResolveToken($ref: TokenRefInput!) {
133
+ tokenByRef(ref: $ref) {
134
+ artworks { id title artists { id name slug } }
135
+ }
136
+ }`, {
137
+ ref: {
138
+ chainId: INDEXER_CHAIN_TO_CAIP[chain],
139
+ contractAddress: contract,
140
+ tokenId,
141
+ },
142
+ });
143
+ const artwork = data.tokenByRef?.artworks?.[0];
144
+ if (!artwork) {
145
+ return null;
129
146
  }
130
- }
131
- /**
132
- * Get the full Raster artwork summary suitable for the one-line resolve
133
- * message ("Snowfro — send/receive — 8977 tokens") and downstream UX.
134
- */
135
- async function getArtworkSummary(artworkId) {
136
- const data = await rasterFetch(`/artwork/${artworkId}`);
137
147
  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 ?? [],
148
+ artworkId: artwork.id,
149
+ title: artwork.title,
150
+ artists: (artwork.artists ?? []).map(toRasterArtist),
147
151
  };
148
152
  }
149
153
  /**
150
- * Resolve an artist's wallet address to a Raster artist id via `/search`.
154
+ * Resolve a wallet address to the Raster artist who claims it.
151
155
  *
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.
156
+ * The `address` query carries artist associations directly (the old REST
157
+ * API needed a `/search` bridge). Returns `null` when no artist claims the
158
+ * address. Checksummed Ethereum input is normalized server-side.
155
159
  */
156
160
  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;
161
+ const data = await rasterQuery(`query ResolveAddress($address: String!) {
162
+ address(address: $address) { artists { id name slug } }
163
+ }`, { address: address.trim() });
164
+ const artist = data.address?.artists?.[0];
165
+ return artist ? toRasterArtist(artist) : null;
176
166
  }
177
167
  /**
178
168
  * List tokens in a Raster artwork (series), one page at a time.
@@ -183,70 +173,79 @@ async function resolveAddressToArtist(address) {
183
173
  * warn-skip line instead of one per token.
184
174
  *
185
175
  * 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.
176
+ * `nextCursor` is `null` on the final page. The API exposes no total count;
177
+ * the only way to learn a series' size is to walk it.
188
178
  */
189
179
  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));
180
+ const data = await rasterQuery(`query ArtworkTokens($id: ID!, $first: Int, $after: String) {
181
+ artwork(id: $id) {
182
+ tokens(first: $first, after: $after) {
183
+ nodes { chainId contractAddress tokenId }
184
+ pageInfo { hasNextPage endCursor }
185
+ }
186
+ }
187
+ }`, { id: artworkId, first: options.pageSize ?? null, after: options.cursor ?? null });
188
+ if (!data.artwork) {
189
+ throw new Error(`Raster: artwork ${artworkId} not found.`);
196
190
  }
197
- const qs = params.toString();
198
- const path = `/artwork/${artworkId}/tokens${qs ? `?${qs}` : ''}`;
199
- const data = await rasterFetch(path);
200
191
  const tokens = [];
201
192
  let skippedUnsupported = 0;
202
- for (const raw of data.tokens ?? []) {
203
- const chain = caipToIndexerChain(raw.chain_id);
204
- if (!chain) {
193
+ for (const raw of data.artwork.tokens.nodes) {
194
+ const chain = caipToIndexerChain(raw.chainId);
195
+ if (!chain || !raw.contractAddress) {
205
196
  skippedUnsupported += 1;
206
197
  continue;
207
198
  }
208
199
  tokens.push({
209
- caipChain: raw.chain_id,
210
200
  chain,
211
- contractAddress: stripContractChainPrefix(raw.contract_address),
212
- tokenId: raw.token_id,
201
+ contractAddress: raw.contractAddress,
202
+ tokenId: raw.tokenId,
213
203
  });
214
204
  }
215
- const rawCursor = data.cursor;
216
- const nextCursor = rawCursor === null || rawCursor >= data.total_count ? null : rawCursor;
205
+ const { hasNextPage, endCursor } = data.artwork.tokens.pageInfo;
217
206
  return {
218
207
  tokens,
219
- totalCount: data.total_count,
220
- nextCursor,
208
+ nextCursor: hasNextPage && endCursor ? endCursor : null,
221
209
  skippedUnsupported,
222
210
  };
223
211
  }
224
212
  /**
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.
213
+ * List an artist's full catalog as light artwork rows (id + title — the
214
+ * API exposes no per-artwork token counts or mint dates yet).
228
215
  *
229
- * The endpoint returns the full catalog in a single response; no cursor is
230
- * exposed by Raster for this path.
216
+ * Walks the artist's `artworks` connection to the end so the caller sees
217
+ * the complete catalog, matching the old single-response REST behavior.
231
218
  */
232
219
  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
- }));
220
+ const rows = [];
221
+ let cursor = null;
222
+ do {
223
+ const data = await rasterQuery(`query ArtistArtworks($id: ID!, $after: String) {
224
+ artist(id: $id) {
225
+ artworks(first: 100, after: $after) {
226
+ nodes { id title }
227
+ pageInfo { hasNextPage endCursor }
228
+ }
229
+ }
230
+ }`, { id: artistId, after: cursor });
231
+ if (!data.artist) {
232
+ throw new Error(`Raster: artist ${artistId} not found.`);
233
+ }
234
+ for (const node of data.artist.artworks.nodes) {
235
+ rows.push({ artworkId: node.id, title: node.title });
236
+ }
237
+ const { hasNextPage, endCursor } = data.artist.artworks.pageInfo;
238
+ cursor = hasNextPage && endCursor ? endCursor : null;
239
+ } while (cursor !== null);
240
+ return rows;
241
241
  }
242
242
  /**
243
243
  * Format a one-line summary for the resolved artwork.
244
244
  *
245
245
  * @example
246
- * formatSummaryLine(summary) // "Snowfro — send/receive — 8977 tokens"
246
+ * formatSummaryLine(summary) // "Snowfro — send/receive"
247
247
  */
248
248
  function formatSummaryLine(summary) {
249
249
  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}`;
250
+ return `${artistNames} — ${summary.title}`;
252
251
  }
@@ -26,7 +26,8 @@ npm run dev -- config show
26
26
  - Used by orchestration to pick API, timeouts, and model identifier.
27
27
 
28
28
  - **defaultDuration** (number, seconds)
29
- - Intended default per‑item display duration. Some flows pass an explicit duration; when omitted, utilities fall back to 10s.
29
+ - Default per‑item display duration for media without an intrinsic runtime (images, code-based works). Falls back to 10s when unset.
30
+ - Does NOT apply to video/audio items: with no explicit duration those are emitted without `duration` and with `display.loop: false`, so a DP-1 player advances at end-of-stream (§4.1) — the media plays its natural length.
30
31
 
31
32
  ## models
32
33
 
@@ -115,6 +116,19 @@ Environment variable alternative:
115
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
116
117
  ```
117
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
+
118
132
  ## ff1Devices
119
133
 
120
134
  Configure devices you want to play content on.
@@ -75,12 +75,14 @@ Two options are available:
75
75
  "quantity": 2
76
76
  }
77
77
  ],
78
- "playlistSettings": { "durationPerItem": 10, "preserveOrder": true, "title": "My Mix" }
78
+ "playlistSettings": { "preserveOrder": true, "title": "My Mix" }
79
79
  }
80
80
  ```
81
81
 
82
82
  This path bypasses the intent parser/orchestrator and calls utilities directly. Validation and sensible defaults are applied in `src/main.ts`.
83
83
 
84
+ `playlistSettings.durationPerItem` is optional. Omit it for auto timing: video/audio items are emitted without `duration` and with `display.loop: false`, so a DP-1 player advances at end-of-stream (§4.1) and the media plays its natural length; static items fall back to `defaultDuration`. Set `durationPerItem` only to force a fixed display time on every item.
85
+
84
86
  2. AI‑orchestrated deterministic build (recommended for prompts): Use `chat` with `--verbose` to see tool calls. The orchestrator enforces complete requirement objects, then runs `verify_playlist` (structure only) before sending.
85
87
 
86
88
  ## Extending Functionality (OSS‑first)
package/docs/README.md CHANGED
@@ -148,6 +148,7 @@ Notes:
148
148
  - Options: `-d, --device <name>`, `--skip-verify` (skip signature verification; not recommended)
149
149
  - `find <input>` – Resolve a marketplace URL, raw `chain:contract:tokenId`, or wallet address into a playable DP-1 playlist
150
150
  - Sources: Art Blocks, Objkt, fxhash, OpenSea, SuperRare, Feral File, Neort, Verse, raw on-chain coordinates, and wallet addresses
151
+ - OpenSea forms: `/assets/ethereum/{contract}/{tokenId}`, `/item/ethereum/{contract}/{tokenId}`, and `/collection/{slug}` (Ethereum collections; the slug is resolved from the public collection page, no API key needed)
151
152
  - Verse forms: `/items/ethereum/{contract}/{tokenId}` and `/series/{slug}`
152
153
  - Options: `-o, --output <path>`, `-l, --limit <n>`, `-p, --play`, `-d, --device <name>`, `--publish`, `-s, --server <index>`, `-y, --yes`, `--skip-verify`
153
154
  - `publish <file>` – Publish a playlist to a feed server (runs `verify` before upload and rejects unsigned or broken playlists)
@@ -343,7 +344,7 @@ Setup preserves existing devices when adding new ones. See selection rules and e
343
344
 
344
345
  - Max 20 items total across all requirements
345
346
  - Per-source caps enforced in utilities
346
- - Duration per item defaults to 10s (configurable)
347
+ - Per-item timing: when no duration is given, video/audio items carry **no** `duration` and `display.loop: false`, so the player advances at end-of-stream (DP-1 §4.1) — the media plays its natural length. Static items use `defaultDuration` (10s unless configured). An explicit duration always wins.
347
348
 
348
349
  ## Links
349
350
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feralfile/cli",
3
- "version": "1.3.1",
3
+ "version": "1.5.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",