@feralfile/cli 2.10.1 → 3.0.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/README.md CHANGED
@@ -26,7 +26,8 @@ npx @feralfile/cli find https://www.artblocks.io/collection/ringers-by-dmitri-ch
26
26
  ```bash
27
27
  ff-cli setup
28
28
  ff-cli find https://objkt.com/tokens/hicetnunc/111068 --play
29
- ff-cli play "https://example.com/video.mp4" --skip-verify
29
+ ff-cli play "https://example.com/video.mp4"
30
+ ff-cli play "https://example.com/" # any web page
30
31
  ```
31
32
 
32
33
  If you need manual config actions instead of guided setup:
@@ -41,9 +42,9 @@ ff-cli config validate
41
42
  `ff-cli setup` is fully scriptable — no TTY required. It runs non-interactively when stdin is not a terminal, or when you pass `-y/--non-interactive`, and takes flags for every step:
42
43
 
43
44
  ```bash
44
- # Generate a signing key and register a device in one shot, no prompts
45
+ # Register a device, no prompts. Generates a signing key only when none is configured,
46
+ # and keeps one that is.
45
47
  ff-cli setup --non-interactive \
46
- --generate-key \
47
48
  --device-host http://192.168.1.50:1111 \
48
49
  --device-name "Living Room"
49
50
 
@@ -51,6 +52,10 @@ ff-cli setup --non-interactive \
51
52
  ff-cli setup -y --key "<private-key>" --role agent
52
53
  ```
53
54
 
55
+ `--generate-key`, `--key`, and `--key-file` refuse to replace a signing key that is already configured, because that key owns the playlists you published. Add `--force` only when you mean to replace it.
56
+
57
+ Without a terminal, `find` needs `-y` (it plays by default; add `--no-play` to only save). A question with no terminal to answer it exits 1 and names the flag.
58
+
54
59
  Device pairing is also scriptable on its own: `ff-cli device add --host http://<ip>:1111 --name <name>` skips mDNS discovery entirely.
55
60
 
56
61
  ## Find an artwork
@@ -75,18 +80,18 @@ Sources: Art Blocks, Objkt, fxhash (canonical `/gentk/...`, live `/iteration/{sl
75
80
 
76
81
  **Known limitations**
77
82
 
78
- - **CryptoPunks (original)** don't index — the contract predates ERC-721 ([ff-indexer-v2#83](https://github.com/feral-file/ff-indexer-v2/issues/83)).
83
+ - **CryptoPunks (original)** don't index — the contract predates ERC-721 ([ff-indexer-v2#83](https://github.com/feral-file/ff-indexer-v2/issues/83)). The opt-in Raster token source resolves them.
79
84
  - **Mainstream PFPs** (Azuki, BAYC, Pudgy Penguins) build a single-item playlist instead of a series — Raster's curation is effectively the series allowlist.
80
85
  - **Indexer 502s on burst load** are intermittent ([ff-indexer-v2#82](https://github.com/feral-file/ff-indexer-v2/issues/82)) — retry clears or use `--limit N`.
86
+ - **The Raster token source is opt-in and under evaluation** (`tokenSource: "raster"`). It cannot list a contract, its Feral File metadata is a snapshot (an evolving work plays the version Raster recorded), and some Feral File works point at a retired image host and play black. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md#tokensource).
81
87
 
82
88
  ## Use with Claude Code
83
89
 
84
90
  ff-cli ships a [Claude Code](https://docs.claude.com/en/docs/claude-code) skill at `skills/ff-control/SKILL.md`. Install it once:
85
91
 
86
92
  ```bash
87
- git clone --depth=1 https://github.com/feral-file/ff-cli /tmp/ff-cli \
88
- && mkdir -p ~/.claude/skills \
89
- && cp -r /tmp/ff-cli/skills/ff-control ~/.claude/skills/
93
+ d="$(mktemp -d)" && git clone --depth=1 https://github.com/feral-file/ff-cli "$d" \
94
+ && mkdir -p ~/.claude/skills && cp -r "$d/skills/ff-control" ~/.claude/skills/
90
95
  ```
91
96
 
92
97
  Claude Code will surface it when you ask to build a playlist, play an artwork or URL on an Art Computer, or publish to a feed. The skill validates config, builds, validates the playlist, and sends or publishes — reporting the failing command + exit code if anything breaks. Full prompt and exact flow: [`skills/ff-control/SKILL.md`](./skills/ff-control/SKILL.md).
@@ -96,9 +101,8 @@ Claude Code will surface it when you ask to build a playlist, play an artwork or
96
101
  ff-cli ships the same `ff-control` skill in Codex's native skill format. Install it once:
97
102
 
98
103
  ```bash
99
- git clone --depth=1 https://github.com/feral-file/ff-cli /tmp/ff-cli \
100
- && mkdir -p ~/.codex/skills \
101
- && cp -r /tmp/ff-cli/skills/ff-control ~/.codex/skills/
104
+ d="$(mktemp -d)" && git clone --depth=1 https://github.com/feral-file/ff-cli "$d" \
105
+ && mkdir -p ~/.codex/skills && cp -r "$d/skills/ff-control" ~/.codex/skills/
102
106
  ```
103
107
 
104
108
  Codex will surface it when you ask to build a playlist, play an artwork or URL on an Art Computer, or publish to a feed. The installed skill is `skills/ff-control/SKILL.md`, so the execution flow stays defined in one place. Codex metadata and exact flow: [`skills/ff-control/agents/openai.yaml`](./skills/ff-control/agents/openai.yaml), [`skills/ff-control/SKILL.md`](./skills/ff-control/SKILL.md).
@@ -109,8 +113,9 @@ Codex will surface it when you ask to build a playlist, play an artwork or URL o
109
113
  npm ci
110
114
  npm run dev -- setup
111
115
  npm run dev -- find https://objkt.com/tokens/hicetnunc/111068 --play
112
- npm run dev -- play "https://example.com/video.mp4" --skip-verify
116
+ npm run dev -- play "https://example.com/video.mp4"
113
117
  ```
118
+
114
119
  ## Documentation
115
120
 
116
121
  - Getting started and usage: `./docs/README.md`
@@ -18,6 +18,11 @@
18
18
  "role": "curator",
19
19
  "curatorName": "YOUR_CURATOR_NAME"
20
20
  },
21
+ "tokenSource": "indexer",
22
+ "ipfsGateways": {
23
+ "feralFile": "https://ipfs.feralfile.com/ipfs/",
24
+ "default": "https://ipfs.filebase.io/ipfs/"
25
+ },
21
26
  "ff1Relayer": {
22
27
  "baseUrl": "https://tv-cast-coordination.autonomy-system.workers.dev"
23
28
  },
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feralfile/cli",
3
- "version": "2.10.1",
3
+ "version": "3.0.0",
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",
@@ -8,11 +8,43 @@ const commander_1 = require("commander");
8
8
  const chalk_1 = __importDefault(require("chalk"));
9
9
  const fs_1 = require("fs");
10
10
  const main_1 = require("../main");
11
+ /**
12
+ * BUILD_PARAMS_HELP documents the params file in `build --help`.
13
+ *
14
+ * The schema used to live only in docs/README.md and the agent skill, so a
15
+ * caller working from `--help` (the first thing an agent reads) had to guess
16
+ * requirement types. Keep it in step with validateRequirements in src/main.ts.
17
+ */
18
+ const BUILD_PARAMS_HELP = `
19
+ Params (JSON file, or piped on stdin):
20
+ {"requirements": [ ...one or more... ], "playlistSettings": { ... }}
21
+
22
+ Requirement types:
23
+ query_address What a wallet owns. ownerAddress (0x… or tz…), optional quantity (number up to 100, or "all").
24
+ {"type":"query_address","ownerAddress":"0x…","quantity":5}
25
+ build_playlist Tokens from a contract. blockchain ("ethereum" or "tezos"), contractAddress, tokenIds.
26
+ {"type":"build_playlist","blockchain":"ethereum","contractAddress":"0x…","tokenIds":["1","2"]}
27
+ fetch_feed A feed playlist by title. playlistName, optional quantity.
28
+ {"type":"fetch_feed","playlistName":"Unsupervised","quantity":3}
29
+ feral_file_artwork One Feral File artwork. artworkId (public id or /exhibitions/artwork/{id} URL).
30
+
31
+ playlistSettings (all optional):
32
+ title, durationPerItem (seconds), preserveOrder (default true; false shuffles),
33
+ deviceName (play on that FF1 after building)
34
+
35
+ Exit status: 1 when a requirement finds nothing or fails, when named tokenIds
36
+ do not all resolve, or when the send to deviceName fails. The playlist is still
37
+ saved when anything was collected.
38
+
39
+ Example:
40
+ echo '{"requirements":[{"type":"query_address","ownerAddress":"0x…","quantity":5}],"playlistSettings":{"title":"My collection"}}' | ff-cli build -o wallet.json
41
+ `;
11
42
  exports.buildCommand = new commander_1.Command('build')
12
43
  .description('Build playlist from structured parameters (JSON file or stdin)')
13
44
  .argument('[params-file]', 'Path to JSON parameters file (or use stdin)')
14
45
  .option('-o, --output <filename>', 'Output filename for the playlist', 'playlist.json')
15
46
  .option('-v, --verbose', 'Show detailed output', false)
47
+ .addHelpText('after', BUILD_PARAMS_HELP)
16
48
  .action(async (paramsFile, options) => {
17
49
  try {
18
50
  let params;
@@ -58,6 +90,14 @@ exports.buildCommand = new commander_1.Command('build')
58
90
  console.log(chalk_1.default.dim(` Items: ${result.playlist.items?.length || 0}`));
59
91
  console.log(chalk_1.default.dim(` Output: ${options.output}\n`));
60
92
  }
93
+ const failures = result?.failures ?? [];
94
+ if (failures.length > 0) {
95
+ console.error(chalk_1.default.red('Not everything you asked for happened:'));
96
+ for (const failure of failures) {
97
+ console.error(chalk_1.default.red(` ${failure}`));
98
+ }
99
+ process.exit(1);
100
+ }
61
101
  }
62
102
  catch (error) {
63
103
  console.error(chalk_1.default.red('\nError:'), error.message);
@@ -26,6 +26,7 @@ exports.configCommand = new commander_1.Command('config')
26
26
  const config = (0, config_1.getConfig)();
27
27
  console.log(chalk_1.default.blue('\nCurrent configuration\n'));
28
28
  console.log(chalk_1.default.bold('Default duration:'), chalk_1.default.white(config.defaultDuration + 's'));
29
+ console.log(chalk_1.default.bold('Token source:'), chalk_1.default.white((0, config_1.getTokenSourceConfig)().source));
29
30
  // The effective list, numbered. `-s <index>` on publish, fetch, replace
30
31
  // and unpublish is an index into exactly this list — whichever of
31
32
  // config.feedServers, the legacy feed.baseURLs, FEED_BASE_URLS, or the
@@ -120,7 +120,7 @@ deviceCommand
120
120
  let prompt = null;
121
121
  const ask = async (question) => {
122
122
  if (!prompt) {
123
- prompt = (0, prompt_1.createPrompt)();
123
+ prompt = (0, prompt_1.createPrompt)('Pass --host http://<device-ip>:1111 and --name <name> to add or update a device without questions.');
124
124
  }
125
125
  return prompt.ask(question);
126
126
  };
@@ -18,15 +18,14 @@ const playlist_verifier_1 = require("../utilities/playlist-verifier");
18
18
  // distinct output here, and treating it as in-place makes the stale-input guard below refuse a write
19
19
  // that was never unsafe. The signer's direct-write path needs the opposite predicate; see same-file.ts.
20
20
  const same_file_1 = require("../utilities/same-file");
21
- // eslint-disable-next-line @typescript-eslint/no-require-imports
22
- const { resolveTokenBatch } = require('../utilities/nft-indexer');
21
+ const token_source_1 = require("../utilities/token-source");
23
22
  /**
24
23
  * writePlaylistAtomically replaces a file only once the new bytes are safely on
25
24
  * disk.
26
25
  *
27
26
  * The default destination is the input file, and a plain write truncates it
28
- * first. Enrichment runs after a lookup that can take minutes while the indexer
29
- * warms tokens, so the window between truncation and a complete write is a
27
+ * first. Enrichment runs after a network lookup that can be slow (minutes on
28
+ * the indexer, which warms unseen tokens), so the window between truncation and a complete write is a
30
29
  * window in which an interruption, a full disk, or an I/O error leaves the
31
30
  * curator with an empty or half-written playlist and no copy of what they had.
32
31
  * A rename within the same directory is atomic, so the original file survives
@@ -295,10 +294,10 @@ const SKIP_COPY = {
295
294
  'external-ref': 'carries an external ref, which outranks an inline manifest',
296
295
  'no-provenance': 'no provenance.contract chain/address/tokenId to look up',
297
296
  'ambiguous-chain': 'chain "evm" names a family; pass --assume-ethereum to assert the network',
298
- // The indexer drops unresolved tokens from its response rather than
299
- // reporting why, so there is no per-item reason to relay here.
300
- 'not-indexed': 'the indexer returned nothing for it',
301
- 'no-metadata': 'the indexer resolved it but has no artist, description, or still image',
297
+ // The lookup adapter keeps only resolved tokens, so there is no per-item
298
+ // reason to relay here.
299
+ 'not-indexed': 'the token source returned nothing for it',
300
+ 'no-metadata': 'the token source resolved it but has no artist, description, or still image',
302
301
  };
303
302
  exports.enrichCommand = new commander_1.Command('enrich')
304
303
  .description('Add missing artist, title, and thumbnail metadata to a playlist')
@@ -322,7 +321,7 @@ exports.enrichCommand = new commander_1.Command('enrich')
322
321
  console.error(chalk_1.default.red('That playlist has no items.'));
323
322
  process.exit(1);
324
323
  }
325
- // Validate before spending minutes on indexer lookups for a document
324
+ // Validate before spending time on token lookups for a document
326
325
  // that was never going to be writable.
327
326
  const before = await (0, playlist_verifier_1.validatePlaylist)(playlist);
328
327
  if (!before.valid) {
@@ -335,20 +334,16 @@ exports.enrichCommand = new commander_1.Command('enrich')
335
334
  printedProgress = true;
336
335
  process.stdout.write(chalk_1.default.dim(`\r ${done}/${count} looked up...`));
337
336
  };
338
- // resolveTokenBatch is (tokens, duration, onProgress) — the second
339
- // positional is DP-1 display seconds, not the callback. Enrichment never
340
- // sets duration (the curator's timing is not ours to touch), so it is
341
- // passed undefined and the callback goes third. find.ts carries the same
342
- // warning; getting this wrong silently returns unusable results.
343
- //
344
- // The raw results are used rather than getNFTTokenInfoBatch's items so
345
- // the indexer's still rides beside each item: the item itself drops the
346
- // still whenever it equals the indexer's chosen source, and enrichment
337
+ // Enrichment never sets duration (the curator's timing is not ours to
338
+ // touch). The raw results are used rather than bare items so the token
339
+ // source's still rides beside each item: the item itself drops the still
340
+ // whenever it equals the source the token source chose, and enrichment
347
341
  // needs it against the curator's source instead.
342
+ const source = (0, token_source_1.getTokenSource)();
348
343
  const lookup = async (tokens, onProgressCallback) => {
349
- const results = await resolveTokenBatch(tokens, undefined, onProgressCallback);
344
+ const results = await source.resolveTokens(tokens, { onProgress: onProgressCallback });
350
345
  return results
351
- .filter((result) => result.success && result.item)
346
+ .filter(token_source_1.isResolved)
352
347
  .map((result) => ({ ...result.item, still: result.still }));
353
348
  };
354
349
  const result = await (0, enrich_playlist_1.enrichPlaylistManifests)(playlist, lookup, {
@@ -375,7 +370,7 @@ exports.enrichCommand = new commander_1.Command('enrich')
375
370
  if (shouldWrite) {
376
371
  // Re-validate the enriched candidate. An inline manifest is schema-
377
372
  // checked the same way a fetched one is (playlists extension §3.6), so
378
- // a malformed manifest from the indexer must not reach the file.
373
+ // a malformed manifest from the token source must not reach the file.
379
374
  const after = await (0, playlist_verifier_1.validatePlaylist)(result.playlist);
380
375
  if (!after.valid) {
381
376
  reportInvalid('Enrichment produced an invalid playlist:', after);
@@ -4,7 +4,6 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.findCommand = void 0;
7
- exports.buildRasterMediaItems = buildRasterMediaItems;
8
7
  exports.parseLimitOption = parseLimitOption;
9
8
  exports.decideActions = decideActions;
10
9
  const commander_1 = require("commander");
@@ -22,9 +21,10 @@ const neort_marketplace_1 = require("../utilities/neort-marketplace");
22
21
  const verse_marketplace_1 = require("../utilities/verse-marketplace");
23
22
  const raster_client_1 = require("../utilities/raster-client");
24
23
  const playlist_cast_1 = require("../utilities/playlist-cast");
25
- // nft-indexer + playlist-builder are still CommonJS; require keeps interop simple.
26
- // eslint-disable-next-line @typescript-eslint/no-require-imports
27
- const { getNFTTokenInfoBatch } = require('../utilities/nft-indexer');
24
+ const token_source_1 = require("../utilities/token-source");
25
+ const token_source_raster_1 = require("../utilities/token-source-raster");
26
+ const config_1 = require("../config");
27
+ // playlist-builder is still CommonJS; require keeps interop simple.
28
28
  // eslint-disable-next-line @typescript-eslint/no-require-imports
29
29
  const { buildDP1Playlist, buildUrlItem } = require('../utilities/playlist-builder');
30
30
  /**
@@ -35,9 +35,26 @@ const { buildDP1Playlist, buildUrlItem } = require('../utilities/playlist-builde
35
35
  */
36
36
  const DP1_MAX_ITEMS = 1024;
37
37
  const RASTER_PAGE_SIZE = 100;
38
+ /**
39
+ * FIND_NON_INTERACTIVE_HINT is what `find` tells a caller without a terminal
40
+ * when one of its questions comes up. `-y` answers every question with its
41
+ * default, and that default is Play, so the hint names `--no-play` in the same
42
+ * breath for a caller that only wants the file.
43
+ */
44
+ const FIND_NON_INTERACTIVE_HINT = 'Re-run with -y to accept the defaults (it builds the playlist and plays it on your FF1); add --no-play to only save it.';
45
+ /**
46
+ * playAPageHint points a caller who handed `find` a page it cannot resolve to
47
+ * the command that shows a page as it is.
48
+ *
49
+ * @param {string} input - The URL the caller gave `find`
50
+ * @returns {string} One line naming `ff-cli play --web`
51
+ */
52
+ function playAPageHint(input) {
53
+ return `To show this page itself on your FF1, run: ff-cli play --web "${input}"`;
54
+ }
38
55
  exports.findCommand = new commander_1.Command('find')
39
56
  .description('Find an artwork on the web and build a DP-1 playlist')
40
- .argument('<input>', 'URL (Objkt / fxhash / Art Blocks / OpenSea / SuperRare / Feral File / Neort / Verse / Raster), `ethereum:{contract}:{tokenId}`, `tezos:{contract}:{tokenId}`, or a wallet address')
57
+ .argument('<input>', 'URL (Objkt / fxhash / Art Blocks / OpenSea / SuperRare / Feral File / Neort / Verse / Raster), `ethereum:{contract}:{tokenId}`, `tezos:{contract}:{tokenId}`, or a wallet address (picks one work by the artist at that address; for what a wallet owns, see `ff-cli build --help`)')
41
58
  .option('-o, --output <path>', 'Save the playlist to this file (default: ./<slug>.json)')
42
59
  .option('-l, --limit <n>', 'Max tokens to include from the series (default: all)')
43
60
  .option('-p, --play', 'Play the playlist on an FF1 device after building')
@@ -56,6 +73,11 @@ exports.findCommand = new commander_1.Command('find')
56
73
  console.error(chalk_1.default.dim('Supported URLs: Objkt, fxhash, Art Blocks, OpenSea, SuperRare, Feral File, Neort, Verse, Raster. ' +
57
74
  'Or: `ethereum:{contract}:{tokenId}`, `tezos:{contract}:{tokenId}`, ' +
58
75
  'or a wallet address (`0x...` / `tz1.../tz2.../tz3...`).'));
76
+ // A plain web page or media URL is not an artwork lookup at all; `play`
77
+ // casts those directly, so name it rather than leave the caller stuck.
78
+ if (/^https?:\/\//i.test(input.trim())) {
79
+ console.error(chalk_1.default.dim(`To play a web page or media URL as it is, run: ff-cli play "${input}"`));
80
+ }
59
81
  process.exit(1);
60
82
  }
61
83
  if (parsed.kind === 'unsupported') {
@@ -65,9 +87,12 @@ exports.findCommand = new commander_1.Command('find')
65
87
  return;
66
88
  }
67
89
  console.error(chalk_1.default.red(parsed.reason));
90
+ // `play` sends unrecognized pages on these hosts here, so the way out
91
+ // of that loop has to be printed on this side too.
92
+ console.error(chalk_1.default.dim(playAPageHint(input)));
68
93
  process.exit(1);
69
94
  }
70
- // Neort is off-chain; its items skip Raster + FF indexer entirely and
95
+ // Neort is off-chain; its items skip the token source entirely and
71
96
  // build a DP-1 entry directly from Neort's API response.
72
97
  if (parsed.kind === 'neort-art') {
73
98
  await runNeortFind(parsed.id, options);
@@ -113,14 +138,13 @@ async function runResolvedTarget(target, options) {
113
138
  else if (target.kind === 'token-list') {
114
139
  const allTokens = target.coords.map((coords) => ({
115
140
  ...coords,
116
- mediaUrl: null,
117
- mediaType: null,
141
+ rasterToken: null,
118
142
  }));
119
143
  tokens = allTokens.slice(0, limit);
120
144
  seriesHasMore = allTokens.length > limit || !!target.hasMore;
121
145
  }
122
146
  else {
123
- tokens = [{ ...target.coords, mediaUrl: null, mediaType: null }];
147
+ tokens = [{ ...target.coords, rasterToken: null }];
124
148
  }
125
149
  if (tokens.length === 0) {
126
150
  console.error(chalk_1.default.red('Series has no tokens on supported chains (Ethereum + Tezos mainnet).'));
@@ -136,48 +160,21 @@ async function runResolvedTarget(target, options) {
136
160
  console.log(chalk_1.default.dim('Cancelled.'));
137
161
  return;
138
162
  }
139
- console.log(chalk_1.default.dim(`Indexing ${tokens.length} token${tokens.length === 1 ? '' : 's'} via FF indexer...`));
140
- // Previously-unseen tokens make the indexer warm renditions by polling —
141
- // minutes of wall time on a large series. Say so upfront (the --limit
142
- // warming hint used to appear only after a failure) and render per-batch
143
- // progress so a long index never reads as a hang.
144
- if (tokens.length > 10) {
163
+ const source = (0, token_source_1.getTokenSource)();
164
+ const label = source.name === 'raster' ? 'Raster' : 'FF indexer';
165
+ console.log(chalk_1.default.dim(`Resolving ${tokens.length} token${tokens.length === 1 ? '' : 's'} via ${label}...`));
166
+ // The indexer warms previously-unseen tokens by polling, which
167
+ // takes minutes on a large series; Raster answers from its own index.
168
+ if (source.name === 'indexer' && tokens.length > 10) {
145
169
  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.'));
146
170
  }
147
- // Second positional arg on getNFTTokenInfoBatch is `duration` (DP-1 item
148
- // display seconds), not concurrency — concurrency is hardcoded inside.
149
- // Omit it for auto timing: video/audio items carry no duration and play
150
- // their natural length (DP-1 §4.1); static items get the config default.
151
- let items = await getNFTTokenInfoBatch(tokens.map((t) => ({
152
- chain: t.chain,
153
- contractAddress: t.contract,
154
- tokenId: t.tokenId,
155
- })), undefined,
156
- // Unconditional: suppressing the done===total call meant a one-batch
157
- // index (including the recommended --limit 5 warm-up) printed nothing
158
- // and multi-batch runs never showed completion. The final N/N line IS
159
- // the "indexing finished" signal.
160
- (done, total) => {
161
- console.log(chalk_1.default.dim(` ${done}/${total} tokens indexed...`));
162
- });
163
- // FF-indexer bypass for Raster-minted tokens. The indexer doesn't carry
164
- // Raster's tokens, so it returns nothing — but Raster handed us each
165
- // token's playable `media.contentUrl`. Build DP-1 items straight from
166
- // that (off-chain provenance, same shape as the Neort path), so the
167
- // series still builds into a signable, verifiable playlist instead of
168
- // failing the way it did before. The indexer is tried first so tokens
169
- // it *can* resolve keep their richer on-chain provenance.
170
- if (!Array.isArray(items) || items.length === 0) {
171
- const rasterItems = buildRasterMediaItems(tokens, target);
172
- if (rasterItems.length > 0) {
173
- console.log(chalk_1.default.dim(` FF indexer returned nothing; building ${rasterItems.length} item${rasterItems.length === 1 ? '' : 's'} directly from Raster media.`));
174
- items = rasterItems;
171
+ const results = await resolveBuildTokens(source, tokens);
172
+ const items = (0, token_source_1.itemsOf)(results);
173
+ if (items.length === 0) {
174
+ console.error(chalk_1.default.red(`No tokens could be resolved via ${label}; cannot build playlist.`));
175
+ for (const message of (0, token_source_1.failureMessages)(results)) {
176
+ console.error(chalk_1.default.dim(` ${message}`));
175
177
  }
176
- }
177
- if (!Array.isArray(items) || items.length === 0) {
178
- console.error(chalk_1.default.red('No tokens could be indexed; cannot build playlist.'));
179
- console.error(chalk_1.default.dim(' The FF indexer may be under load when warming a series of previously-unseen tokens.'));
180
- console.error(chalk_1.default.dim(' Try `--limit 5` first to warm the cache, then re-run without --limit.'));
181
178
  process.exit(1);
182
179
  }
183
180
  const title = playlistTitleFor(target, items);
@@ -188,7 +185,10 @@ async function runResolvedTarget(target, options) {
188
185
  console.log(chalk_1.default.dim(` ${items.length} item${items.length === 1 ? '' : 's'}`));
189
186
  const dropped = tokens.length - items.length;
190
187
  if (dropped > 0) {
191
- console.log(chalk_1.default.dim(` ${dropped} token${dropped === 1 ? '' : 's'} dropped during indexing`));
188
+ console.log(chalk_1.default.dim(` ${dropped} token${dropped === 1 ? '' : 's'} could not be resolved:`));
189
+ for (const message of (0, token_source_1.failureMessages)(results)) {
190
+ console.log(chalk_1.default.dim(` ${message}`));
191
+ }
192
192
  }
193
193
  console.log();
194
194
  const actions = await decideActions(options);
@@ -279,6 +279,11 @@ async function resolveTarget(input, parsed, skipPrompt, limitOption) {
279
279
  if (catalog.length === 0) {
280
280
  throw new Error('Artist has no artworks indexed on Raster.');
281
281
  }
282
+ // A wallet address reads naturally as "what this wallet owns", and this
283
+ // path builds something else: one work by the artist at the address. Say
284
+ // so every time, so neither a person nor an agent mistakes the result.
285
+ console.log(chalk_1.default.dim('find on an address plays one work by the artist at that address. ' +
286
+ 'For what the wallet owns, use build with a query_address requirement (ff-cli build --help).'));
282
287
  const picked = await pickFromCatalog(catalog, skipPrompt);
283
288
  return {
284
289
  kind: 'series',
@@ -345,7 +350,7 @@ async function resolveCoords(coords) {
345
350
  }
346
351
  /**
347
352
  * Build the playlist title. For series, "Artist — Series Title". For a
348
- * one-item fallback, use the indexer's token name (already in the item)
353
+ * one-item fallback, use the token source's name (already in the item)
349
354
  * with a fall-through to coords if name is missing.
350
355
  */
351
356
  function playlistTitleFor(target, items) {
@@ -359,30 +364,30 @@ function playlistTitleFor(target, items) {
359
364
  return items[0]?.title ?? `Token ${target.coords.tokenId}`;
360
365
  }
361
366
  /**
362
- * Build DP-1 items directly from Raster's per-token media, bypassing the FF
363
- * indexer. Used as a fallback when the indexer resolves nothing — tokens
364
- * without a Raster `mediaUrl` are dropped (nothing playable to point at).
367
+ * resolveBuildTokens turns the tokens to build into results, one per token.
365
368
  *
366
- * Item shape matches the Neort off-chain path (`buildUrlItem` →
367
- * `provenance.offChainURI`), so the resulting playlist signs and verifies
368
- * normally. Titles are "<Series> #<tokenId>" for series, falling back to the
369
- * series/coords title for a single token.
369
+ * When every token already carries its Raster row (a Raster series page) and
370
+ * Raster is the token source, the rows are mapped directly: the page already
371
+ * paid for every field, so asking again would double the requests. Any other
372
+ * mix goes through the token source by coordinate.
370
373
  */
371
- function buildRasterMediaItems(tokens, target) {
372
- const seriesTitle = target.kind === 'series' ? target.summary.title : undefined;
373
- const items = [];
374
- for (const t of tokens) {
375
- if (!t.mediaUrl) {
376
- continue;
377
- }
378
- const title = seriesTitle ? `${seriesTitle} #${t.tokenId}` : `Token ${t.tokenId}`;
379
- // No explicit duration: auto timing (video/audio play their natural
380
- // length). `mediaType` carries Raster's type so an extensionless IPFS
381
- // contentUrl is still classified correctly — otherwise a video would be
382
- // stamped a fixed duration and play as a still.
383
- items.push(buildUrlItem(t.mediaUrl, undefined, { title, mimeType: t.mediaType ?? undefined }));
384
- }
385
- return items;
374
+ async function resolveBuildTokens(source, tokens) {
375
+ const rows = tokens.map((t) => t.rasterToken);
376
+ if (source.name === 'raster' && rows.every((row) => row !== null)) {
377
+ const entries = tokens.map((t, i) => ({ token: rows[i], requested: coordinateOf(t) }));
378
+ return (0, token_source_raster_1.itemsFromRasterTokens)(entries, (0, config_1.getTokenSourceConfig)().ipfsGateways);
379
+ }
380
+ // Unconditional progress: the final N/N line IS the "resolution finished"
381
+ // signal, including for a single-batch run.
382
+ return source.resolveTokens(tokens.map(coordinateOf), {
383
+ onProgress: (done, total) => {
384
+ console.log(chalk_1.default.dim(` ${done}/${total} tokens resolved...`));
385
+ },
386
+ });
387
+ }
388
+ /** coordinateOf converts a find token to the token source's coordinate shape. */
389
+ function coordinateOf(token) {
390
+ return { chain: token.chain, contractAddress: token.contract, tokenId: token.tokenId };
386
391
  }
387
392
  function parseLimitOption(limitStr) {
388
393
  if (limitStr === undefined) {
@@ -425,8 +430,7 @@ async function fetchTokens(summary, limit) {
425
430
  chain: t.chain,
426
431
  contract: t.contractAddress,
427
432
  tokenId: t.tokenId,
428
- mediaUrl: t.mediaUrl,
429
- mediaType: t.mediaType,
433
+ rasterToken: t.token,
430
434
  });
431
435
  }
432
436
  if (hasMore || page.nextCursor === null) {
@@ -439,7 +443,7 @@ async function fetchTokens(summary, limit) {
439
443
  cursor = page.nextCursor;
440
444
  }
441
445
  if (skipped > 0) {
442
- console.log(chalk_1.default.yellow(` Skipped ${skipped} token${skipped === 1 ? '' : 's'} on unsupported chains (FF indexer covers eth + tezos mainnet only).`));
446
+ console.log(chalk_1.default.yellow(` Skipped ${skipped} token${skipped === 1 ? '' : 's'} on unsupported chains (ff-cli builds Ethereum and Tezos mainnet only).`));
443
447
  }
444
448
  return { tokens: collected, hasMore };
445
449
  }
@@ -452,7 +456,7 @@ async function pickFromCatalog(catalog, skipPrompt) {
452
456
  console.log(chalk_1.default.dim(` ${i}: ${row.title}`));
453
457
  });
454
458
  console.log();
455
- const prompt = (0, prompt_1.createPrompt)();
459
+ const prompt = (0, prompt_1.createPrompt)(FIND_NON_INTERACTIVE_HINT);
456
460
  const answer = await prompt.ask(`Pick one (0-${catalog.length - 1}, default 0): `);
457
461
  prompt.close();
458
462
  console.log();
@@ -467,7 +471,7 @@ async function pickFromCatalog(catalog, skipPrompt) {
467
471
  async function confirmMakePlaylist(count, hasMore) {
468
472
  const noun = count === 1 ? 'token' : 'tokens';
469
473
  const label = hasMore ? `the first ${count} ${noun}` : `${count} ${noun}`;
470
- const prompt = (0, prompt_1.createPrompt)();
474
+ const prompt = (0, prompt_1.createPrompt)(FIND_NON_INTERACTIVE_HINT);
471
475
  const yes = await (0, prompt_1.promptYesNo)(prompt.ask, `Build playlist with ${label}?`, true);
472
476
  prompt.close();
473
477
  console.log();
@@ -510,7 +514,7 @@ async function decideActions(options) {
510
514
  return [await promptNextAction()];
511
515
  }
512
516
  async function promptNextAction() {
513
- const prompt = (0, prompt_1.createPrompt)();
517
+ const prompt = (0, prompt_1.createPrompt)('The playlist is saved. Pass --play to cast it, or --publish to publish it.');
514
518
  const answer = (await prompt.ask('Next? [P]lay on FF1 / just [S]ave / pub[L]ish to feed (default: Play): '))
515
519
  .trim()
516
520
  .toLowerCase();
@@ -610,7 +614,7 @@ async function doPublish(savedPath, serverArg, nonInteractive) {
610
614
  }
611
615
  /**
612
616
  * Neort flow. Off-chain platform with no on-chain coords, so we bypass
613
- * Raster + FF indexer and construct a single-item DP-1 playlist directly
617
+ * the token source and construct a single-item DP-1 playlist directly
614
618
  * from Neort's API response using the off-chain `provenance.offChainURI`
615
619
  * shape produced by `buildUrlItem`.
616
620
  */