@feralfile/cli 2.10.0 → 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.0",
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",
@@ -65,7 +65,7 @@
65
65
  "chalk": "^4.1.2",
66
66
  "commander": "^11.1.0",
67
67
  "dotenv": "^16.3.1",
68
- "dp1-js": "2.3.1",
68
+ "dp1-js": "2.4.1",
69
69
  "ff1": "^1.0.0",
70
70
  "fuzzysort": "^3.1.0",
71
71
  "viem": "^2.56.3"
@@ -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,16 +35,34 @@ 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')
61
+ .option('--no-play', 'Never cast to a device; combine with -y to build and save without playing')
44
62
  .option('-d, --device <name>', 'Device to play on (used with --play; default: first configured)')
45
63
  .option('--publish', 'Publish the playlist to a configured feed server')
46
64
  .option('-s, --server <index>', 'Feed server index (used with --publish)')
47
- .option('-y, --yes', 'Skip interactive prompts; defaults to Play unless --output or --publish is set')
65
+ .option('-y, --yes', 'Skip interactive prompts. Without --output or --publish, -y plays the playlist on a device; add --no-play to skip casting')
48
66
  .option('--skip-verify', 'Skip signature verification before --play (mirrors `ff-cli play --skip-verify`)')
49
67
  .action(async (input, options) => {
50
68
  try {
@@ -55,6 +73,11 @@ exports.findCommand = new commander_1.Command('find')
55
73
  console.error(chalk_1.default.dim('Supported URLs: Objkt, fxhash, Art Blocks, OpenSea, SuperRare, Feral File, Neort, Verse, Raster. ' +
56
74
  'Or: `ethereum:{contract}:{tokenId}`, `tezos:{contract}:{tokenId}`, ' +
57
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
+ }
58
81
  process.exit(1);
59
82
  }
60
83
  if (parsed.kind === 'unsupported') {
@@ -64,9 +87,12 @@ exports.findCommand = new commander_1.Command('find')
64
87
  return;
65
88
  }
66
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)));
67
93
  process.exit(1);
68
94
  }
69
- // 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
70
96
  // build a DP-1 entry directly from Neort's API response.
71
97
  if (parsed.kind === 'neort-art') {
72
98
  await runNeortFind(parsed.id, options);
@@ -112,14 +138,13 @@ async function runResolvedTarget(target, options) {
112
138
  else if (target.kind === 'token-list') {
113
139
  const allTokens = target.coords.map((coords) => ({
114
140
  ...coords,
115
- mediaUrl: null,
116
- mediaType: null,
141
+ rasterToken: null,
117
142
  }));
118
143
  tokens = allTokens.slice(0, limit);
119
144
  seriesHasMore = allTokens.length > limit || !!target.hasMore;
120
145
  }
121
146
  else {
122
- tokens = [{ ...target.coords, mediaUrl: null, mediaType: null }];
147
+ tokens = [{ ...target.coords, rasterToken: null }];
123
148
  }
124
149
  if (tokens.length === 0) {
125
150
  console.error(chalk_1.default.red('Series has no tokens on supported chains (Ethereum + Tezos mainnet).'));
@@ -135,48 +160,21 @@ async function runResolvedTarget(target, options) {
135
160
  console.log(chalk_1.default.dim('Cancelled.'));
136
161
  return;
137
162
  }
138
- console.log(chalk_1.default.dim(`Indexing ${tokens.length} token${tokens.length === 1 ? '' : 's'} via FF indexer...`));
139
- // Previously-unseen tokens make the indexer warm renditions by polling —
140
- // minutes of wall time on a large series. Say so upfront (the --limit
141
- // warming hint used to appear only after a failure) and render per-batch
142
- // progress so a long index never reads as a hang.
143
- 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) {
144
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.'));
145
170
  }
146
- // Second positional arg on getNFTTokenInfoBatch is `duration` (DP-1 item
147
- // display seconds), not concurrency — concurrency is hardcoded inside.
148
- // Omit it for auto timing: video/audio items carry no duration and play
149
- // their natural length (DP-1 §4.1); static items get the config default.
150
- let items = await getNFTTokenInfoBatch(tokens.map((t) => ({
151
- chain: t.chain,
152
- contractAddress: t.contract,
153
- tokenId: t.tokenId,
154
- })), undefined,
155
- // Unconditional: suppressing the done===total call meant a one-batch
156
- // index (including the recommended --limit 5 warm-up) printed nothing
157
- // and multi-batch runs never showed completion. The final N/N line IS
158
- // the "indexing finished" signal.
159
- (done, total) => {
160
- console.log(chalk_1.default.dim(` ${done}/${total} tokens indexed...`));
161
- });
162
- // FF-indexer bypass for Raster-minted tokens. The indexer doesn't carry
163
- // Raster's tokens, so it returns nothing — but Raster handed us each
164
- // token's playable `media.contentUrl`. Build DP-1 items straight from
165
- // that (off-chain provenance, same shape as the Neort path), so the
166
- // series still builds into a signable, verifiable playlist instead of
167
- // failing the way it did before. The indexer is tried first so tokens
168
- // it *can* resolve keep their richer on-chain provenance.
169
- if (!Array.isArray(items) || items.length === 0) {
170
- const rasterItems = buildRasterMediaItems(tokens, target);
171
- if (rasterItems.length > 0) {
172
- console.log(chalk_1.default.dim(` FF indexer returned nothing; building ${rasterItems.length} item${rasterItems.length === 1 ? '' : 's'} directly from Raster media.`));
173
- 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}`));
174
177
  }
175
- }
176
- if (!Array.isArray(items) || items.length === 0) {
177
- console.error(chalk_1.default.red('No tokens could be indexed; cannot build playlist.'));
178
- console.error(chalk_1.default.dim(' The FF indexer may be under load when warming a series of previously-unseen tokens.'));
179
- console.error(chalk_1.default.dim(' Try `--limit 5` first to warm the cache, then re-run without --limit.'));
180
178
  process.exit(1);
181
179
  }
182
180
  const title = playlistTitleFor(target, items);
@@ -187,7 +185,10 @@ async function runResolvedTarget(target, options) {
187
185
  console.log(chalk_1.default.dim(` ${items.length} item${items.length === 1 ? '' : 's'}`));
188
186
  const dropped = tokens.length - items.length;
189
187
  if (dropped > 0) {
190
- 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
+ }
191
192
  }
192
193
  console.log();
193
194
  const actions = await decideActions(options);
@@ -278,6 +279,11 @@ async function resolveTarget(input, parsed, skipPrompt, limitOption) {
278
279
  if (catalog.length === 0) {
279
280
  throw new Error('Artist has no artworks indexed on Raster.');
280
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).'));
281
287
  const picked = await pickFromCatalog(catalog, skipPrompt);
282
288
  return {
283
289
  kind: 'series',
@@ -344,7 +350,7 @@ async function resolveCoords(coords) {
344
350
  }
345
351
  /**
346
352
  * Build the playlist title. For series, "Artist — Series Title". For a
347
- * 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)
348
354
  * with a fall-through to coords if name is missing.
349
355
  */
350
356
  function playlistTitleFor(target, items) {
@@ -358,30 +364,30 @@ function playlistTitleFor(target, items) {
358
364
  return items[0]?.title ?? `Token ${target.coords.tokenId}`;
359
365
  }
360
366
  /**
361
- * Build DP-1 items directly from Raster's per-token media, bypassing the FF
362
- * indexer. Used as a fallback when the indexer resolves nothing — tokens
363
- * without a Raster `mediaUrl` are dropped (nothing playable to point at).
367
+ * resolveBuildTokens turns the tokens to build into results, one per token.
364
368
  *
365
- * Item shape matches the Neort off-chain path (`buildUrlItem` →
366
- * `provenance.offChainURI`), so the resulting playlist signs and verifies
367
- * normally. Titles are "<Series> #<tokenId>" for series, falling back to the
368
- * 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.
369
373
  */
370
- function buildRasterMediaItems(tokens, target) {
371
- const seriesTitle = target.kind === 'series' ? target.summary.title : undefined;
372
- const items = [];
373
- for (const t of tokens) {
374
- if (!t.mediaUrl) {
375
- continue;
376
- }
377
- const title = seriesTitle ? `${seriesTitle} #${t.tokenId}` : `Token ${t.tokenId}`;
378
- // No explicit duration: auto timing (video/audio play their natural
379
- // length). `mediaType` carries Raster's type so an extensionless IPFS
380
- // contentUrl is still classified correctly — otherwise a video would be
381
- // stamped a fixed duration and play as a still.
382
- items.push(buildUrlItem(t.mediaUrl, undefined, { title, mimeType: t.mediaType ?? undefined }));
383
- }
384
- 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 };
385
391
  }
386
392
  function parseLimitOption(limitStr) {
387
393
  if (limitStr === undefined) {
@@ -424,8 +430,7 @@ async function fetchTokens(summary, limit) {
424
430
  chain: t.chain,
425
431
  contract: t.contractAddress,
426
432
  tokenId: t.tokenId,
427
- mediaUrl: t.mediaUrl,
428
- mediaType: t.mediaType,
433
+ rasterToken: t.token,
429
434
  });
430
435
  }
431
436
  if (hasMore || page.nextCursor === null) {
@@ -438,7 +443,7 @@ async function fetchTokens(summary, limit) {
438
443
  cursor = page.nextCursor;
439
444
  }
440
445
  if (skipped > 0) {
441
- 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).`));
442
447
  }
443
448
  return { tokens: collected, hasMore };
444
449
  }
@@ -451,7 +456,7 @@ async function pickFromCatalog(catalog, skipPrompt) {
451
456
  console.log(chalk_1.default.dim(` ${i}: ${row.title}`));
452
457
  });
453
458
  console.log();
454
- const prompt = (0, prompt_1.createPrompt)();
459
+ const prompt = (0, prompt_1.createPrompt)(FIND_NON_INTERACTIVE_HINT);
455
460
  const answer = await prompt.ask(`Pick one (0-${catalog.length - 1}, default 0): `);
456
461
  prompt.close();
457
462
  console.log();
@@ -466,7 +471,7 @@ async function pickFromCatalog(catalog, skipPrompt) {
466
471
  async function confirmMakePlaylist(count, hasMore) {
467
472
  const noun = count === 1 ? 'token' : 'tokens';
468
473
  const label = hasMore ? `the first ${count} ${noun}` : `${count} ${noun}`;
469
- const prompt = (0, prompt_1.createPrompt)();
474
+ const prompt = (0, prompt_1.createPrompt)(FIND_NON_INTERACTIVE_HINT);
470
475
  const yes = await (0, prompt_1.promptYesNo)(prompt.ask, `Build playlist with ${label}?`, true);
471
476
  prompt.close();
472
477
  console.log();
@@ -478,9 +483,16 @@ async function confirmMakePlaylist(count, hasMore) {
478
483
  * Precedence: explicit flags (--play / --publish) win and may combine. If no
479
484
  * flag is set, fall back to --yes default (Play) or an interactive 3-way
480
485
  * prompt. Save is unconditional and already done before this is called.
486
+ *
487
+ * `--no-play` (commander sets `play === false`) is an opt-out that removes
488
+ * casting from every path, including the `--yes` default and the interactive
489
+ * prompt. It is additive: `--yes` alone keeps its deliberate "max-satisfaction
490
+ * default for scripted use" of Play, and this must not change that. Because
491
+ * `--no-play` already states the user's intent, it never opens the prompt.
481
492
  */
482
493
  async function decideActions(options) {
483
494
  const flagActions = [];
495
+ const noPlay = options.play === false;
484
496
  if (options.play) {
485
497
  flagActions.push('play');
486
498
  }
@@ -491,7 +503,8 @@ async function decideActions(options) {
491
503
  return flagActions;
492
504
  }
493
505
  // --output alone (with or without --yes) means "save mode" — the save was the action.
494
- if (options.output) {
506
+ // --no-play likewise means the save was the action; never prompt or default to Play.
507
+ if (options.output || noPlay) {
495
508
  return [];
496
509
  }
497
510
  // --yes without --output defaults to Play (max-satisfaction default for scripted use).
@@ -501,7 +514,7 @@ async function decideActions(options) {
501
514
  return [await promptNextAction()];
502
515
  }
503
516
  async function promptNextAction() {
504
- 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.');
505
518
  const answer = (await prompt.ask('Next? [P]lay on FF1 / just [S]ave / pub[L]ish to feed (default: Play): '))
506
519
  .trim()
507
520
  .toLowerCase();
@@ -601,7 +614,7 @@ async function doPublish(savedPath, serverArg, nonInteractive) {
601
614
  }
602
615
  /**
603
616
  * Neort flow. Off-chain platform with no on-chain coords, so we bypass
604
- * 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
605
618
  * from Neort's API response using the off-chain `provenance.offChainURI`
606
619
  * shape produced by `buildUrlItem`.
607
620
  */