@feralfile/cli 2.10.1 → 4.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.
Files changed (59) hide show
  1. package/README.md +31 -23
  2. package/config.json.example +6 -1
  3. package/dist/index.js +10 -2
  4. package/dist/package.json +5 -2
  5. package/dist/src/commands/build.js +40 -0
  6. package/dist/src/commands/config.js +1 -0
  7. package/dist/src/commands/device.js +85 -40
  8. package/dist/src/commands/enrich.js +18 -23
  9. package/dist/src/commands/find.js +144 -106
  10. package/dist/src/commands/helpers/device-control.js +937 -0
  11. package/dist/src/commands/helpers/hidden-alias-help.js +37 -0
  12. package/dist/src/commands/helpers/playlist-display.js +2 -15
  13. package/dist/src/commands/helpers/prompt.js +85 -10
  14. package/dist/src/commands/play.js +162 -84
  15. package/dist/src/commands/report.js +251 -0
  16. package/dist/src/commands/setup.js +82 -7
  17. package/dist/src/commands/status.js +4 -1
  18. package/dist/src/config.js +256 -6
  19. package/dist/src/main.js +14 -5
  20. package/dist/src/utilities/control/codec.js +578 -0
  21. package/dist/src/utilities/control/control-errors.js +157 -0
  22. package/dist/src/utilities/control/controller-store.js +964 -0
  23. package/dist/src/utilities/control/jwk.js +141 -0
  24. package/dist/src/utilities/control/pairing-channel.js +52 -0
  25. package/dist/src/utilities/control/pairing.js +416 -0
  26. package/dist/src/utilities/control/registration.js +391 -0
  27. package/dist/src/utilities/control/relay-pairing-channel.js +191 -0
  28. package/dist/src/utilities/control/secure-transport.js +850 -0
  29. package/dist/src/utilities/control/strict-json.js +376 -0
  30. package/dist/src/utilities/device-lookup.js +36 -11
  31. package/dist/src/utilities/device-network.js +129 -0
  32. package/dist/src/utilities/device-upsert.js +1 -1
  33. package/dist/src/utilities/feral-file-artwork.js +2 -2
  34. package/dist/src/utilities/ff1-compatibility.js +87 -11
  35. package/dist/src/utilities/ff1-device.js +161 -78
  36. package/dist/src/utilities/ff1-relayer.js +34 -14
  37. package/dist/src/utilities/handoff-client.js +23 -9
  38. package/dist/src/utilities/index.js +270 -259
  39. package/dist/src/utilities/media-url.js +117 -0
  40. package/dist/src/utilities/neort-marketplace.js +3 -3
  41. package/dist/src/utilities/nft-indexer.js +12 -376
  42. package/dist/src/utilities/playlist-builder.js +11 -2
  43. package/dist/src/utilities/playlist-cast.js +6 -6
  44. package/dist/src/utilities/playlist-source.js +223 -34
  45. package/dist/src/utilities/raster-client.js +101 -14
  46. package/dist/src/utilities/relay-client.js +774 -0
  47. package/dist/src/utilities/ssh-access.js +192 -58
  48. package/dist/src/utilities/support-report.js +511 -0
  49. package/dist/src/utilities/temporary-channel.js +139 -0
  50. package/dist/src/utilities/token-item.js +428 -0
  51. package/dist/src/utilities/token-source-contract.js +55 -0
  52. package/dist/src/utilities/token-source-indexer.js +99 -0
  53. package/dist/src/utilities/token-source-raster.js +378 -0
  54. package/dist/src/utilities/token-source.js +48 -0
  55. package/docs/CONFIGURATION.md +331 -28
  56. package/docs/EXAMPLES.md +33 -33
  57. package/docs/PROJECT_SPEC.md +55 -36
  58. package/docs/README.md +145 -43
  59. package/package.json +5 -2
@@ -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,18 +35,56 @@ 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 to display, so the hint names `--no-display` in
42
+ * the same 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 displays it on your FF1); add --no-display 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 display --web`
51
+ */
52
+ function playAPageHint(input) {
53
+ return `To show this page itself on your FF1, run: ff-cli display --web "${input}"`;
54
+ }
55
+ /**
56
+ * displayIntent folds the hidden `--play` / `--no-play` spellings into
57
+ * `--display` / `--no-display`.
58
+ *
59
+ * The verb for art is display, not play (B&F weekly 2026-11-03; Canon
60
+ * reference/naming-brand.md §3.4), so `find` takes `--display` and
61
+ * `--no-display`. `-p, --play` and `--no-play` stay accepted, hidden from
62
+ * help, so existing scripts and agents keep working. Commander stores the two
63
+ * spellings under separate keys (`display`, `play`); this is the one place
64
+ * that reads both. The current spelling wins when a caller passes both.
65
+ *
66
+ * @param {FindOptions} options - Parsed `find` options
67
+ * @returns {boolean | undefined} true to display, false to never cast,
68
+ * undefined when neither spelling was passed
69
+ */
70
+ function displayIntent(options) {
71
+ return options.display ?? options.play;
72
+ }
38
73
  exports.findCommand = new commander_1.Command('find')
39
74
  .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')
75
+ .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
76
  .option('-o, --output <path>', 'Save the playlist to this file (default: ./<slug>.json)')
42
77
  .option('-l, --limit <n>', 'Max tokens to include from the series (default: all)')
43
- .option('-p, --play', 'Play the playlist on an FF1 device after building')
44
- .option('--no-play', 'Never cast to a device; combine with -y to build and save without playing')
45
- .option('-d, --device <name>', 'Device to play on (used with --play; default: first configured)')
78
+ .option('--display', 'Display the playlist on an FF1 device after building')
79
+ .option('--no-display', 'Never cast to a device; combine with -y to build and save without displaying')
80
+ // The retired spellings of the two flags above, accepted but never listed; see displayIntent.
81
+ .addOption(new commander_1.Option('-p, --play').hideHelp())
82
+ .addOption(new commander_1.Option('--no-play').hideHelp())
83
+ .option('-d, --device <name>', 'Device to display on (used with --display; default: first configured)')
46
84
  .option('--publish', 'Publish the playlist to a configured feed server')
47
85
  .option('-s, --server <index>', 'Feed server index (used with --publish)')
48
- .option('-y, --yes', 'Skip interactive prompts. Without --output or --publish, -y plays the playlist on a device; add --no-play to skip casting')
49
- .option('--skip-verify', 'Skip signature verification before --play (mirrors `ff-cli play --skip-verify`)')
86
+ .option('-y, --yes', 'Skip interactive prompts. Without --output or --publish, -y displays the playlist on a device; add --no-display to skip casting')
87
+ .option('--skip-verify', 'Skip signature verification before --display (mirrors `ff-cli display --skip-verify`)')
50
88
  .action(async (input, options) => {
51
89
  try {
52
90
  console.log(chalk_1.default.blue('\nFind on Feral File CLI\n'));
@@ -56,6 +94,11 @@ exports.findCommand = new commander_1.Command('find')
56
94
  console.error(chalk_1.default.dim('Supported URLs: Objkt, fxhash, Art Blocks, OpenSea, SuperRare, Feral File, Neort, Verse, Raster. ' +
57
95
  'Or: `ethereum:{contract}:{tokenId}`, `tezos:{contract}:{tokenId}`, ' +
58
96
  'or a wallet address (`0x...` / `tz1.../tz2.../tz3...`).'));
97
+ // A plain web page or media URL is not an artwork lookup at all; `display`
98
+ // casts those directly, so name it rather than leave the caller stuck.
99
+ if (/^https?:\/\//i.test(input.trim())) {
100
+ console.error(chalk_1.default.dim(`To display a web page or media URL as it is, run: ff-cli display "${input}"`));
101
+ }
59
102
  process.exit(1);
60
103
  }
61
104
  if (parsed.kind === 'unsupported') {
@@ -65,9 +108,12 @@ exports.findCommand = new commander_1.Command('find')
65
108
  return;
66
109
  }
67
110
  console.error(chalk_1.default.red(parsed.reason));
111
+ // `display` sends unrecognized pages on these hosts here, so the way out
112
+ // of that loop has to be printed on this side too.
113
+ console.error(chalk_1.default.dim(playAPageHint(input)));
68
114
  process.exit(1);
69
115
  }
70
- // Neort is off-chain; its items skip Raster + FF indexer entirely and
116
+ // Neort is off-chain; its items skip the token source entirely and
71
117
  // build a DP-1 entry directly from Neort's API response.
72
118
  if (parsed.kind === 'neort-art') {
73
119
  await runNeortFind(parsed.id, options);
@@ -113,14 +159,13 @@ async function runResolvedTarget(target, options) {
113
159
  else if (target.kind === 'token-list') {
114
160
  const allTokens = target.coords.map((coords) => ({
115
161
  ...coords,
116
- mediaUrl: null,
117
- mediaType: null,
162
+ rasterToken: null,
118
163
  }));
119
164
  tokens = allTokens.slice(0, limit);
120
165
  seriesHasMore = allTokens.length > limit || !!target.hasMore;
121
166
  }
122
167
  else {
123
- tokens = [{ ...target.coords, mediaUrl: null, mediaType: null }];
168
+ tokens = [{ ...target.coords, rasterToken: null }];
124
169
  }
125
170
  if (tokens.length === 0) {
126
171
  console.error(chalk_1.default.red('Series has no tokens on supported chains (Ethereum + Tezos mainnet).'));
@@ -136,48 +181,21 @@ async function runResolvedTarget(target, options) {
136
181
  console.log(chalk_1.default.dim('Cancelled.'));
137
182
  return;
138
183
  }
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) {
184
+ const source = (0, token_source_1.getTokenSource)();
185
+ const label = source.name === 'raster' ? 'Raster' : 'FF indexer';
186
+ console.log(chalk_1.default.dim(`Resolving ${tokens.length} token${tokens.length === 1 ? '' : 's'} via ${label}...`));
187
+ // The indexer warms previously-unseen tokens by polling, which
188
+ // takes minutes on a large series; Raster answers from its own index.
189
+ if (source.name === 'indexer' && tokens.length > 10) {
145
190
  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
191
  }
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;
192
+ const results = await resolveBuildTokens(source, tokens);
193
+ const items = (0, token_source_1.itemsOf)(results);
194
+ if (items.length === 0) {
195
+ console.error(chalk_1.default.red(`No tokens could be resolved via ${label}; cannot build playlist.`));
196
+ for (const message of (0, token_source_1.failureMessages)(results)) {
197
+ console.error(chalk_1.default.dim(` ${message}`));
175
198
  }
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
199
  process.exit(1);
182
200
  }
183
201
  const title = playlistTitleFor(target, items);
@@ -188,7 +206,10 @@ async function runResolvedTarget(target, options) {
188
206
  console.log(chalk_1.default.dim(` ${items.length} item${items.length === 1 ? '' : 's'}`));
189
207
  const dropped = tokens.length - items.length;
190
208
  if (dropped > 0) {
191
- console.log(chalk_1.default.dim(` ${dropped} token${dropped === 1 ? '' : 's'} dropped during indexing`));
209
+ console.log(chalk_1.default.dim(` ${dropped} token${dropped === 1 ? '' : 's'} could not be resolved:`));
210
+ for (const message of (0, token_source_1.failureMessages)(results)) {
211
+ console.log(chalk_1.default.dim(` ${message}`));
212
+ }
192
213
  }
193
214
  console.log();
194
215
  const actions = await decideActions(options);
@@ -279,6 +300,11 @@ async function resolveTarget(input, parsed, skipPrompt, limitOption) {
279
300
  if (catalog.length === 0) {
280
301
  throw new Error('Artist has no artworks indexed on Raster.');
281
302
  }
303
+ // A wallet address reads naturally as "what this wallet owns", and this
304
+ // path builds something else: one work by the artist at the address. Say
305
+ // so every time, so neither a person nor an agent mistakes the result.
306
+ console.log(chalk_1.default.dim('find on an address displays one work by the artist at that address. ' +
307
+ 'For what the wallet owns, use build with a query_address requirement (ff-cli build --help).'));
282
308
  const picked = await pickFromCatalog(catalog, skipPrompt);
283
309
  return {
284
310
  kind: 'series',
@@ -318,11 +344,11 @@ async function resolveTokenListInput(input, limit, fallbackTitle = 'Resolved sou
318
344
  }
319
345
  /**
320
346
  * Resolve on-chain coords to a target. If Raster doesn't index this token,
321
- * fall back to single-token mode so we still build something playable.
347
+ * fall back to single-token mode so we still build something to display.
322
348
  *
323
349
  * Raster being unreachable gets the same fallback: it only enriches the find
324
350
  * (series enumeration, artist labels) — the coords in hand are enough to
325
- * build a playable single-token playlist, and a network blip on one optional
351
+ * build a single-token playlist to display, and a network blip on one optional
326
352
  * dependency must not kill the whole command (#97).
327
353
  */
328
354
  async function resolveCoords(coords) {
@@ -345,7 +371,7 @@ async function resolveCoords(coords) {
345
371
  }
346
372
  /**
347
373
  * 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)
374
+ * one-item fallback, use the token source's name (already in the item)
349
375
  * with a fall-through to coords if name is missing.
350
376
  */
351
377
  function playlistTitleFor(target, items) {
@@ -359,30 +385,30 @@ function playlistTitleFor(target, items) {
359
385
  return items[0]?.title ?? `Token ${target.coords.tokenId}`;
360
386
  }
361
387
  /**
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).
388
+ * resolveBuildTokens turns the tokens to build into results, one per token.
365
389
  *
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.
390
+ * When every token already carries its Raster row (a Raster series page) and
391
+ * Raster is the token source, the rows are mapped directly: the page already
392
+ * paid for every field, so asking again would double the requests. Any other
393
+ * mix goes through the token source by coordinate.
370
394
  */
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;
395
+ async function resolveBuildTokens(source, tokens) {
396
+ const rows = tokens.map((t) => t.rasterToken);
397
+ if (source.name === 'raster' && rows.every((row) => row !== null)) {
398
+ const entries = tokens.map((t, i) => ({ token: rows[i], requested: coordinateOf(t) }));
399
+ return (0, token_source_raster_1.itemsFromRasterTokens)(entries, (0, config_1.getTokenSourceConfig)().ipfsGateways);
400
+ }
401
+ // Unconditional progress: the final N/N line IS the "resolution finished"
402
+ // signal, including for a single-batch run.
403
+ return source.resolveTokens(tokens.map(coordinateOf), {
404
+ onProgress: (done, total) => {
405
+ console.log(chalk_1.default.dim(` ${done}/${total} tokens resolved...`));
406
+ },
407
+ });
408
+ }
409
+ /** coordinateOf converts a find token to the token source's coordinate shape. */
410
+ function coordinateOf(token) {
411
+ return { chain: token.chain, contractAddress: token.contract, tokenId: token.tokenId };
386
412
  }
387
413
  function parseLimitOption(limitStr) {
388
414
  if (limitStr === undefined) {
@@ -425,8 +451,7 @@ async function fetchTokens(summary, limit) {
425
451
  chain: t.chain,
426
452
  contract: t.contractAddress,
427
453
  tokenId: t.tokenId,
428
- mediaUrl: t.mediaUrl,
429
- mediaType: t.mediaType,
454
+ rasterToken: t.token,
430
455
  });
431
456
  }
432
457
  if (hasMore || page.nextCursor === null) {
@@ -439,7 +464,7 @@ async function fetchTokens(summary, limit) {
439
464
  cursor = page.nextCursor;
440
465
  }
441
466
  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).`));
467
+ console.log(chalk_1.default.yellow(` Skipped ${skipped} token${skipped === 1 ? '' : 's'} on unsupported chains (ff-cli builds Ethereum and Tezos mainnet only).`));
443
468
  }
444
469
  return { tokens: collected, hasMore };
445
470
  }
@@ -452,7 +477,7 @@ async function pickFromCatalog(catalog, skipPrompt) {
452
477
  console.log(chalk_1.default.dim(` ${i}: ${row.title}`));
453
478
  });
454
479
  console.log();
455
- const prompt = (0, prompt_1.createPrompt)();
480
+ const prompt = (0, prompt_1.createPrompt)(FIND_NON_INTERACTIVE_HINT);
456
481
  const answer = await prompt.ask(`Pick one (0-${catalog.length - 1}, default 0): `);
457
482
  prompt.close();
458
483
  console.log();
@@ -467,7 +492,7 @@ async function pickFromCatalog(catalog, skipPrompt) {
467
492
  async function confirmMakePlaylist(count, hasMore) {
468
493
  const noun = count === 1 ? 'token' : 'tokens';
469
494
  const label = hasMore ? `the first ${count} ${noun}` : `${count} ${noun}`;
470
- const prompt = (0, prompt_1.createPrompt)();
495
+ const prompt = (0, prompt_1.createPrompt)(FIND_NON_INTERACTIVE_HINT);
471
496
  const yes = await (0, prompt_1.promptYesNo)(prompt.ask, `Build playlist with ${label}?`, true);
472
497
  prompt.close();
473
498
  console.log();
@@ -476,20 +501,23 @@ async function confirmMakePlaylist(count, hasMore) {
476
501
  /**
477
502
  * Decide which post-build actions to run.
478
503
  *
479
- * Precedence: explicit flags (--play / --publish) win and may combine. If no
480
- * flag is set, fall back to --yes default (Play) or an interactive 3-way
481
- * prompt. Save is unconditional and already done before this is called.
504
+ * Precedence: explicit flags (--display / --publish) win and may combine. If
505
+ * no flag is set, fall back to the --yes default (display) or an interactive
506
+ * 3-way prompt. Save is unconditional and already done before this is called.
482
507
  *
483
- * `--no-play` (commander sets `play === false`) is an opt-out that removes
484
- * casting from every path, including the `--yes` default and the interactive
485
- * prompt. It is additive: `--yes` alone keeps its deliberate "max-satisfaction
486
- * default for scripted use" of Play, and this must not change that. Because
487
- * `--no-play` already states the user's intent, it never opens the prompt.
508
+ * `--no-display` (commander sets `display === false`) is an opt-out that
509
+ * removes casting from every path, including the `--yes` default and the
510
+ * interactive prompt. It is additive: `--yes` alone keeps its deliberate
511
+ * "max-satisfaction default for scripted use" of displaying, and this must not
512
+ * change that. Because `--no-display` already states the user's intent, it
513
+ * never opens the prompt. The hidden `--play` / `--no-play` spellings mean the
514
+ * same (displayIntent). The internal action keeps its name, 'play'.
488
515
  */
489
516
  async function decideActions(options) {
490
517
  const flagActions = [];
491
- const noPlay = options.play === false;
492
- if (options.play) {
518
+ const display = displayIntent(options);
519
+ const noDisplay = display === false;
520
+ if (display) {
493
521
  flagActions.push('play');
494
522
  }
495
523
  if (options.publish) {
@@ -499,24 +527,34 @@ async function decideActions(options) {
499
527
  return flagActions;
500
528
  }
501
529
  // --output alone (with or without --yes) means "save mode" — the save was the action.
502
- // --no-play likewise means the save was the action; never prompt or default to Play.
503
- if (options.output || noPlay) {
530
+ // --no-display likewise means the save was the action; never prompt or default to display.
531
+ if (options.output || noDisplay) {
504
532
  return [];
505
533
  }
506
- // --yes without --output defaults to Play (max-satisfaction default for scripted use).
534
+ // --yes without --output defaults to display (max-satisfaction default for scripted use).
507
535
  if (options.yes) {
508
536
  return ['play'];
509
537
  }
510
538
  return [await promptNextAction()];
511
539
  }
540
+ /**
541
+ * promptNextAction asks what to do with a saved playlist when no flag said so.
542
+ *
543
+ * `p` and `play` stay accepted after the rename to display: a script that
544
+ * pipes its answer in must keep getting the same action.
545
+ *
546
+ * @returns {Promise<PostBuildAction>} The chosen action
547
+ * @throws {Error} On an answer that names no action
548
+ */
512
549
  async function promptNextAction() {
513
- const prompt = (0, prompt_1.createPrompt)();
514
- const answer = (await prompt.ask('Next? [P]lay on FF1 / just [S]ave / pub[L]ish to feed (default: Play): '))
550
+ const prompt = (0, prompt_1.createPrompt)('The playlist is saved. Pass --display to cast it, or --publish to publish it.');
551
+ const answer = (await prompt.ask('Next? [D]isplay on FF1 / just [S]ave / pub[L]ish to feed (default: Display): '))
515
552
  .trim()
516
553
  .toLowerCase();
517
554
  prompt.close();
518
555
  console.log();
519
- if (!answer || answer === 'p' || answer === 'play' || answer === 'y' || answer === 'yes') {
556
+ const displayAnswers = ['', 'd', 'display', 'p', 'play', 'y', 'yes'];
557
+ if (displayAnswers.includes(answer)) {
520
558
  return 'play';
521
559
  }
522
560
  if (answer === 's' || answer === 'save' || answer === 'n' || answer === 'no') {
@@ -525,10 +563,10 @@ async function promptNextAction() {
525
563
  if (answer === 'l' || answer === 'pub' || answer === 'publish') {
526
564
  return 'publish';
527
565
  }
528
- throw new Error(`Unrecognized choice: "${answer}". Use P, S, or L.`);
566
+ throw new Error(`Unrecognized choice: "${answer}". Use D, S, or L.`);
529
567
  }
530
568
  async function doPlay(playlist, deviceName, skipVerify) {
531
- // Same verify → send gate `ff-cli play` uses, via the shared castPlaylist
569
+ // Same verify → send gate `ff-cli display` uses, via the shared castPlaylist
532
570
  // helper. `find`'s playlists are already signed by buildDP1Playlist (when a
533
571
  // key is configured), so no signing is requested here — an unsigned one
534
572
  // fails the verify gate with an actionable message instead of reaching the
@@ -549,7 +587,7 @@ async function doPlay(playlist, deviceName, skipVerify) {
549
587
  'or pass --skip-verify to bypass.'));
550
588
  }
551
589
  else {
552
- console.error(chalk_1.default.red('Play failed:'), result.error);
590
+ console.error(chalk_1.default.red('Display failed:'), result.error);
553
591
  if (result.deviceDetails) {
554
592
  console.error(chalk_1.default.dim(` Details: ${result.deviceDetails}`));
555
593
  }
@@ -560,8 +598,8 @@ async function doPlay(playlist, deviceName, skipVerify) {
560
598
  if (result.verified) {
561
599
  console.log(chalk_1.default.green('✓ Verified\n'));
562
600
  }
563
- console.log(chalk_1.default.blue('Play on FF1'));
564
- console.log(chalk_1.default.green('✓ Playing'));
601
+ console.log(chalk_1.default.blue('Display on FF1'));
602
+ console.log(chalk_1.default.green('✓ Displaying'));
565
603
  if (result.deviceName) {
566
604
  console.log(chalk_1.default.dim(` Device: ${result.deviceName}`));
567
605
  }
@@ -610,7 +648,7 @@ async function doPublish(savedPath, serverArg, nonInteractive) {
610
648
  }
611
649
  /**
612
650
  * 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
651
+ * the token source and construct a single-item DP-1 playlist directly
614
652
  * from Neort's API response using the off-chain `provenance.offChainURI`
615
653
  * shape produced by `buildUrlItem`.
616
654
  */