@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
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.hiddenAliasHelp = void 0;
4
+ const commander_1 = require("commander");
5
+ /**
6
+ * dropAlias removes `cmd`'s first alias from a help term commander built, turning `display|play` back
7
+ * into `display`.
8
+ *
9
+ * Commander adds the alias as `name|alias` in exactly these two terms and nowhere else, so replacing
10
+ * that one token is enough; rebuilding the terms from scratch would copy commander internals that
11
+ * are not exported (humanReadableArgName).
12
+ *
13
+ * @param {string} term - The term commander built
14
+ * @param {Command} cmd - The command it describes
15
+ * @returns {string} The term without the alias
16
+ */
17
+ function dropAlias(term, cmd) {
18
+ const alias = cmd.alias();
19
+ return alias ? term.replace(`${cmd.name()}|${alias}`, cmd.name()) : term;
20
+ }
21
+ /**
22
+ * hiddenAliasHelp is a commander help configuration that names a command by its name only, never
23
+ * by its alias.
24
+ *
25
+ * An alias in ff-cli is a retired command name kept so existing scripts and agents keep working:
26
+ * `play` became `display` (B&F weekly 2026-11-03; Canon reference/naming-brand.md §3.4). Commander
27
+ * would print it in the command list and the usage line (`display|play`), and help is where people
28
+ * and agents learn the CLI's words, so printing it would teach the retired verb again. The alias
29
+ * still parses and `ff-cli help play` still works; only the printed help drops it.
30
+ *
31
+ * Apply it to the program (the command list uses subcommandTerm) and to every aliased command (its
32
+ * own usage line uses commandUsage): `addCommand` does not copy help configuration to subcommands.
33
+ */
34
+ exports.hiddenAliasHelp = {
35
+ subcommandTerm: (cmd) => dropAlias(new commander_1.Help().subcommandTerm(cmd), cmd),
36
+ commandUsage: (cmd) => dropAlias(new commander_1.Help().commandUsage(cmd), cmd),
37
+ };
@@ -3,7 +3,6 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.displayPlaylistSummary = displayPlaylistSummary;
7
6
  exports.printPlaylistSourceLoadFailure = printPlaylistSourceLoadFailure;
8
7
  exports.printPlaylistVerificationFailure = printPlaylistVerificationFailure;
9
8
  exports.validatePlaylistSource = validatePlaylistSource;
@@ -11,18 +10,6 @@ exports.runValidateCommand = runValidateCommand;
11
10
  exports.runVerifyCommand = runVerifyCommand;
12
11
  const chalk_1 = __importDefault(require("chalk"));
13
12
  const playlist_source_1 = require("../../utilities/playlist-source");
14
- /**
15
- * Print the standard "playlist saved" footer with the next-step hint.
16
- *
17
- * Used by `chat` and `build` after they write a playlist to disk, so the
18
- * surface message stays consistent across both commands.
19
- */
20
- function displayPlaylistSummary(playlist, outputPath) {
21
- console.log(chalk_1.default.green('\nPlaylist saved'));
22
- console.log(chalk_1.default.dim(` Output: ./${outputPath}`));
23
- console.log(chalk_1.default.dim(' Next: play last | publish playlist'));
24
- console.log();
25
- }
26
13
  /**
27
14
  * Print a focused failure for playlist source loading problems with a
28
15
  * URL-vs-file aware hint block.
@@ -45,7 +32,7 @@ function printPlaylistSourceLoadFailure(source, error) {
45
32
  }
46
33
  /**
47
34
  * Print playlist validation failure details consistently for commands that
48
- * validate structure before sending (for example `play`).
35
+ * validate structure before sending (for example `display`).
49
36
  */
50
37
  function printPlaylistVerificationFailure(verifyResult, source) {
51
38
  console.error(chalk_1.default.red(`\nPlaylist validation failed:${source ? ` (${source})` : ''}`), verifyResult.error);
@@ -55,7 +42,7 @@ function printPlaylistVerificationFailure(verifyResult, source) {
55
42
  console.log(chalk_1.default.yellow(` • ${detail.path}: ${detail.message}`));
56
43
  });
57
44
  }
58
- console.log(chalk_1.default.yellow('\n Use --skip-verify to play anyway (not recommended)\n'));
45
+ console.log(chalk_1.default.yellow('\n Use --skip-verify to display anyway (not recommended)\n'));
59
46
  }
60
47
  /**
61
48
  * Load a playlist from a path or URL and run parse-only validation (`validatePlaylist`).
@@ -36,23 +36,98 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.NonInteractiveError = exports.NON_TTY_ANSWER_TIMEOUT_MS = void 0;
40
+ exports.isNonInteractiveError = isNonInteractiveError;
39
41
  exports.createPrompt = createPrompt;
40
42
  exports.promptYesNo = promptYesNo;
41
43
  const readline = __importStar(require("readline"));
42
44
  const chalk_1 = __importDefault(require("chalk"));
43
- function createPrompt() {
44
- const rl = readline.createInterface({
45
- input: process.stdin,
46
- output: process.stdout,
47
- });
48
- const ask = async (question) => new Promise((resolve) => {
49
- rl.question(chalk_1.default.yellow(question), (answer) => {
50
- resolve(answer.trim());
45
+ /**
46
+ * NON_TTY_ANSWER_TIMEOUT_MS bounds how long a question waits for an answer on
47
+ * a stdin that is not a terminal.
48
+ *
49
+ * Piped answers (`echo n | ff-cli find …`) arrive at once and still work. An
50
+ * agent harness that leaves stdin open but never writes to it would otherwise
51
+ * wait forever, which reads as a hang the agent cannot diagnose.
52
+ */
53
+ exports.NON_TTY_ANSWER_TIMEOUT_MS = 10_000;
54
+ /**
55
+ * NonInteractiveError reports a question that got no answer because stdin is
56
+ * not a terminal: input ended first, or nothing arrived in time.
57
+ *
58
+ * Before this error, end-of-input left readline's callback uncalled and the
59
+ * process exited 0 having done nothing (`find <url>` printed "Build playlist?
60
+ * [Y/n]" and quit). That looks like success to a caller that trusts exit
61
+ * codes, so the question fails loudly instead, naming what would answer it.
62
+ *
63
+ * Callers match on `name` (isNonInteractiveError), not `instanceof`: tests
64
+ * replace this module with a stub that does not export the class.
65
+ */
66
+ class NonInteractiveError extends Error {
67
+ constructor(question, hint) {
68
+ const asked = question.trim();
69
+ super(`"${asked}" needs an answer, and none came: stdin is not a terminal.${hint ? ` ${hint}` : ''}`);
70
+ this.name = 'NonInteractiveError';
71
+ }
72
+ }
73
+ exports.NonInteractiveError = NonInteractiveError;
74
+ /**
75
+ * isNonInteractiveError reports whether an error came from a question that
76
+ * got no answer on a non-terminal stdin.
77
+ *
78
+ * @param {unknown} error - Any caught value
79
+ * @returns {boolean} True for a NonInteractiveError
80
+ */
81
+ function isNonInteractiveError(error) {
82
+ return error instanceof Error && error.name === 'NonInteractiveError';
83
+ }
84
+ /**
85
+ * createPrompt returns a prompt that asks on stdin and never waits silently.
86
+ *
87
+ * The readline interface opens on the first question, not at creation, so a
88
+ * command that never asks never attaches to stdin. A question rejects when
89
+ * input ends before an answer (on a terminal that is Ctrl-D; otherwise a
90
+ * NonInteractiveError), and, when stdin is not a terminal, when no answer
91
+ * arrives within NON_TTY_ANSWER_TIMEOUT_MS.
92
+ *
93
+ * @param {string} [nonInteractiveHint] - What answers the question without a
94
+ * terminal (usually a flag); appended to the NonInteractiveError message
95
+ * @param {Object} [options] - Test seams
96
+ * @param {PromptInput} [options.input] - Stream to read answers from (default stdin)
97
+ * @param {number} [options.answerTimeoutMs] - Non-terminal wait bound
98
+ * @returns {Prompt} ask/close pair
99
+ */
100
+ function createPrompt(nonInteractiveHint, options = {}) {
101
+ const input = options.input ?? process.stdin;
102
+ const timeoutMs = options.answerTimeoutMs ?? exports.NON_TTY_ANSWER_TIMEOUT_MS;
103
+ let rl = null;
104
+ const ask = async (question) => {
105
+ rl ??= readline.createInterface({ input, output: process.stdout });
106
+ const iface = rl;
107
+ return new Promise((resolve, reject) => {
108
+ let timer;
109
+ const settle = (finish) => {
110
+ clearTimeout(timer);
111
+ iface.off('close', onClose);
112
+ finish();
113
+ };
114
+ const noAnswer = () => input.isTTY
115
+ ? new Error('Input closed before an answer was given.')
116
+ : new NonInteractiveError(question, nonInteractiveHint);
117
+ const onClose = () => settle(() => reject(noAnswer()));
118
+ iface.once('close', onClose);
119
+ if (!input.isTTY) {
120
+ timer = setTimeout(() => settle(() => {
121
+ reject(noAnswer());
122
+ iface.close();
123
+ }), timeoutMs);
124
+ }
125
+ iface.question(chalk_1.default.yellow(question), (answer) => settle(() => resolve(answer.trim())));
51
126
  });
52
- });
127
+ };
53
128
  return {
54
129
  ask,
55
- close: () => rl.close(),
130
+ close: () => rl?.close(),
56
131
  };
57
132
  }
58
133
  async function promptYesNo(ask, question, defaultYes = true) {
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.playCommand = void 0;
6
+ exports.displayCommand = void 0;
7
7
  exports.describeDiscoveryInput = describeDiscoveryInput;
8
8
  exports.probeDiscoveryUrlAsPlaylist = probeDiscoveryUrlAsPlaylist;
9
9
  const commander_1 = require("commander");
@@ -13,20 +13,21 @@ const config_1 = require("../config");
13
13
  const playlist_source_1 = require("../utilities/playlist-source");
14
14
  const playlist_cast_1 = require("../utilities/playlist-cast");
15
15
  const playlist_display_1 = require("./helpers/playlist-display");
16
+ const hidden_alias_help_1 = require("./helpers/hidden-alias-help");
16
17
  /**
17
- * Human label for a discovery input that `play` cannot cast directly.
18
+ * Human label for a discovery input that `display` cannot cast directly.
18
19
  *
19
- * `play` casts an exact, already-playable source (a playlist file, a hosted
20
- * playlist URL, or a direct media/web URL). A marketplace URL, on-chain
20
+ * `display` casts an exact source it can send as it is (a playlist file, a
21
+ * hosted playlist URL, or a direct media/web URL). A marketplace URL, on-chain
21
22
  * coordinates, or a wallet address must be *resolved* through the indexer
22
- * first — that is `find`'s job. Returns a label for those inputs so `play`
23
+ * first — that is `find`'s job. Returns a label for those inputs so `display`
23
24
  * can redirect the user instead of silently wrapping, say, an Art Blocks
24
- * collection *page* as a web item. Returns null for anything `play` should
25
+ * collection *page* as a web item. Returns null for anything `display` should
25
26
  * handle itself (plain URLs and file paths parse to null here).
26
27
  *
27
28
  * A recognized-but-`unsupported` result (e.g. a legacy Art Blocks
28
29
  * `/projects/{id}` URL, or an unsupported-chain OpenSea link) is also a
29
- * discovery input — it's a marketplace URL, not a playable file — so it is
30
+ * discovery input — it's a marketplace URL, not a file it can send — so it is
30
31
  * redirected to `find` too, which surfaces the specific reason.
31
32
  */
32
33
  function describeDiscoveryInput(source) {
@@ -72,15 +73,21 @@ function describeDiscoveryInput(source) {
72
73
  * which wrongly redirected DP-1 feed playlist URLs
73
74
  * (https://feed.feralfile.com/api/v1/playlists/{slug}, exactly the URLs the
74
75
  * feed hands out) to `find`, which has no feed route either; the documented
75
- * "hosted playlist URL plays directly" contract was unreachable for them
76
+ * "hosted playlist URL displays directly" contract was unreachable for them
76
77
  * (found 2026-08-02 casting a published playlist to an FF1).
77
78
  *
78
79
  * Probing the document keeps both behaviors without a host allowlist: a URL
79
- * that serves DP-1 JSON (`dpVersion` marker) plays directly; a marketplace
80
+ * that serves DP-1 JSON (`dpVersion` marker) displays directly; a marketplace
80
81
  * *page* fails the probe (non-JSON, or JSON with no marker) and still
81
82
  * redirects to `find` instead of being wrapped as a web item. The probe
82
83
  * costs one fetch before a redirect, only for discovery inputs that are
83
84
  * http(s) URLs; coordinates and wallet addresses never hit the network.
85
+ *
86
+ * A URL that does not exist (404/410, no such host, a broken `.json` link)
87
+ * is refused here rather than redirected: the redirect also suggests
88
+ * `display --web`, which would cast the error page.
89
+ *
90
+ * @throws {Error} When the URL does not exist (refusalForLoadFailure)
84
91
  */
85
92
  async function probeDiscoveryUrlAsPlaylist(source) {
86
93
  if (!(0, playlist_source_1.isPlaylistSourceUrl)(source)) {
@@ -99,89 +106,63 @@ async function probeDiscoveryUrlAsPlaylist(source) {
99
106
  source: loaded.source,
100
107
  };
101
108
  }
102
- catch {
109
+ catch (error) {
110
+ const refusal = (0, playlist_source_1.refusalForLoadFailure)(source.trim(), error);
111
+ if (refusal) {
112
+ throw refusal;
113
+ }
103
114
  return null;
104
115
  }
105
116
  }
106
- exports.playCommand = new commander_1.Command('play')
107
- .description('Play a playlist or media URL on an FF1 device')
108
- .argument('<source>', 'Playlist file, playlist URL, or media URL')
117
+ /**
118
+ * resolveWebPageSource wraps `source` as a web page for `display --web`, without
119
+ * the discovery redirect or the playlist probe.
120
+ *
121
+ * `--web` exists because the discovery parser claims whole hosts: any page on
122
+ * feralfile.com, opensea.io, objkt.com and the like is redirected to `find`,
123
+ * and `find` rejects every page that is not an artwork it can resolve (a
124
+ * homepage, an exhibition overview, a profile). Before `--web` there was no
125
+ * way to put such a page on display; the two commands pointed at each other.
126
+ *
127
+ * @param {string} source - The URL the caller passed
128
+ * @returns {Promise<PlaySource>} A `media` source with a `text/html` item
129
+ * @throws {Error} When `source` is not an http(s) URL
130
+ */
131
+ async function resolveWebPageSource(source) {
132
+ const url = source.trim();
133
+ if (!(0, playlist_source_1.isPlaylistSourceUrl)(url)) {
134
+ throw new Error(`--web takes an http(s) URL; got "${source}".`);
135
+ }
136
+ const note = await (0, playlist_source_1.checkWebPageUrl)(url);
137
+ return (0, playlist_source_1.buildMediaPlaySource)(url, { asWebPage: true, note });
138
+ }
139
+ /**
140
+ * displayCommand puts a playlist, media URL, or web page on an FF1.
141
+ *
142
+ * The verb for art is display, not play (B&F weekly 2026-11-03; Canon
143
+ * reference/naming-brand.md §3.4): people play games and video, they display
144
+ * art. `play` was this command's name, so it stays as an alias that existing
145
+ * scripts and agents keep calling; hiddenAliasHelp keeps it out of every help
146
+ * screen so help teaches only `display`. Do not drop the alias without a
147
+ * decision to break those callers.
148
+ */
149
+ exports.displayCommand = new commander_1.Command('display')
150
+ .alias('play')
151
+ .configureHelp(hidden_alias_help_1.hiddenAliasHelp)
152
+ .description('Display a playlist file, playlist URL, media URL, or web page on an FF1 device')
153
+ .argument('<source>', 'Playlist file, playlist URL, or any media or web page URL')
109
154
  .option('-d, --device <name>', 'Device name (uses first device if not specified)')
110
- .option('--skip-verify', 'Skip signature verification and auto-sign gating before playing; use only when you accept malformed envelopes')
155
+ .option('--web', 'Display the URL as a web page, even on a marketplace site where display would otherwise send you to find')
156
+ .option('--skip-verify', 'Skip signature verification and auto-sign gating before displaying; use only when you accept malformed envelopes')
111
157
  .action(async (source, options) => {
112
158
  try {
113
- // `play` casts an exact playable source. A marketplace URL / on-chain
114
- // coords / wallet must be resolved through the indexer first — redirect
115
- // to `find` rather than silently wrapping the *page* as a web item.
116
- // Exception: a discovery-classified URL that actually serves a DP-1
117
- // playlist document (the discovery parser claims whole hosts, catching
118
- // feed playlist URLs) plays directly — see probeDiscoveryUrlAsPlaylist.
119
- const discovery = describeDiscoveryInput(source);
120
- let probed = null;
121
- if (discovery) {
122
- probed = await probeDiscoveryUrlAsPlaylist(source);
123
- }
124
- if (discovery && !probed) {
125
- console.error(chalk_1.default.red('\n`play` casts an exact playlist or media URL — it does not search marketplaces.'));
126
- console.error(chalk_1.default.dim(` "${source}" looks like ${discovery}, which needs to be resolved first.`));
127
- const deviceFlag = options.device ? ` -d "${options.device}"` : '';
128
- console.log(chalk_1.default.yellow(`\nTry: ff-cli find "${source}" --play${deviceFlag}`));
129
- process.exit(1);
130
- }
131
- // No explicit duration: a direct video/audio URL plays its natural
132
- // length (DP-1 §4.1); static media uses config.defaultDuration.
133
- const resolved = probed ?? (await (0, playlist_source_1.resolvePlaySource)(source));
134
- const isPlaylistSource = resolved.kind === 'playlist';
135
- const sourceLabel = isPlaylistSource
136
- ? `${resolved.sourceType}: ${resolved.source}`
137
- : resolved.source;
138
- console.log(chalk_1.default.blue('\nPlay on FF1\n'));
139
- if (!options.skipVerify && isPlaylistSource) {
140
- console.log(chalk_1.default.cyan(`Verify playlist (${sourceLabel})`));
141
- }
142
- // A direct media/web URL is wrapped in an unsigned single-item playlist,
143
- // so it must be signed before the verify gate; a loaded playlist is sent
144
- // as-is. castPlaylist owns the sign → verify → send sequence.
145
- // Resolve through getPlaylistConfig rather than reading config.playlist directly: it is the one
146
- // place that knows a config value may still be the config.json.example placeholder, in which case a
147
- // valid PLAYLIST_PRIVATE_KEY must win. Duplicating the `config || env` precedence here is what made
148
- // this path sign with the template literal while every other signing path used the real key.
149
- const privateKey = (0, config_1.getPlaylistConfig)().privateKey || undefined;
150
- const result = await (0, playlist_cast_1.castPlaylist)(resolved.playlist, {
151
- deviceName: options.device,
152
- skipVerify: options.skipVerify,
153
- requireSignature: resolved.kind === 'media',
154
- signingKey: privateKey,
155
- });
156
- if (!result.success) {
157
- if (result.stage === 'verify') {
158
- (0, playlist_display_1.printPlaylistVerificationFailure)({ valid: false, error: result.error, details: result.details }, isPlaylistSource ? `source: ${sourceLabel}` : undefined);
159
- }
160
- else if (result.stage === 'sign') {
161
- console.error(chalk_1.default.red(`\n${result.error}`));
162
- }
163
- else {
164
- console.error(chalk_1.default.red('\nPlay failed:'), result.error);
165
- if (result.deviceDetails) {
166
- console.error(chalk_1.default.dim(` Details: ${result.deviceDetails}`));
167
- }
168
- }
169
- process.exit(1);
170
- }
171
- if (result.verified) {
172
- console.log(chalk_1.default.green(result.signed ? '✓ Signed and verified\n' : '✓ Verified\n'));
173
- }
174
- console.log(chalk_1.default.green('✓ Playing'));
175
- if (result.deviceName) {
176
- console.log(chalk_1.default.dim(` Device: ${result.deviceName}`));
177
- }
178
- if (result.device) {
179
- console.log(chalk_1.default.dim(` Host: ${result.device}`));
180
- }
181
- console.log();
159
+ const resolved = options.web
160
+ ? await resolveWebPageSource(source)
161
+ : await resolveDefaultSource(source, options.device);
162
+ await castResolved(resolved, options);
182
163
  }
183
164
  catch (error) {
184
- if ((0, playlist_source_1.isPlaylistSourceUrl)(source)) {
165
+ if (!options.web && (0, playlist_source_1.isPlaylistSourceUrl)(source)) {
185
166
  (0, playlist_display_1.printPlaylistSourceLoadFailure)(source, error);
186
167
  }
187
168
  else {
@@ -190,3 +171,100 @@ exports.playCommand = new commander_1.Command('play')
190
171
  process.exit(1);
191
172
  }
192
173
  });
174
+ /**
175
+ * resolveDefaultSource resolves a `display` argument without `--web`: discovery
176
+ * inputs go to `find`, everything else loads as a playlist or wraps as media.
177
+ *
178
+ * `display` casts an exact source as it is. A marketplace URL / on-chain coords /
179
+ * wallet must be resolved through the indexer first, so it redirects to `find`
180
+ * rather than silently wrapping the *page* as a web item. Exception: a
181
+ * discovery-classified URL that actually serves a DP-1 playlist document (the
182
+ * discovery parser claims whole hosts, catching feed playlist URLs) displays
183
+ * directly; see probeDiscoveryUrlAsPlaylist. The redirect names `--web` too,
184
+ * for the caller who did mean the page.
185
+ *
186
+ * @param {string} source - The URL, path, or reference the caller passed
187
+ * @param {string} [device] - Device name, carried into the suggested command
188
+ * @returns {Promise<PlaySource>} What to cast; exits 1 on a discovery input
189
+ */
190
+ async function resolveDefaultSource(source, device) {
191
+ const discovery = describeDiscoveryInput(source);
192
+ let probed = null;
193
+ if (discovery) {
194
+ probed = await probeDiscoveryUrlAsPlaylist(source);
195
+ }
196
+ if (discovery && !probed) {
197
+ console.error(chalk_1.default.red('\n`display` casts an exact playlist or media URL — it does not search marketplaces.'));
198
+ console.error(chalk_1.default.dim(` "${source}" looks like ${discovery}, which needs to be resolved first.`));
199
+ const deviceFlag = device ? ` -d "${device}"` : '';
200
+ console.log(chalk_1.default.yellow(`\nTry: ff-cli find "${source}" --display -y${deviceFlag}`));
201
+ if ((0, playlist_source_1.isPlaylistSourceUrl)(source)) {
202
+ console.log(chalk_1.default.dim(`To show the page itself: ff-cli display --web "${source}"${deviceFlag}`));
203
+ }
204
+ process.exit(1);
205
+ }
206
+ // No explicit duration: a direct video/audio URL plays its natural
207
+ // length (DP-1 §4.1); static media uses config.defaultDuration.
208
+ return probed ?? (await (0, playlist_source_1.resolvePlaySource)(source));
209
+ }
210
+ /**
211
+ * castResolved signs (for a synthesized media playlist), verifies, and sends
212
+ * a resolved source, then reports the outcome. Exits 1 on any failure.
213
+ *
214
+ * @param {PlaySource} resolved - What to cast
215
+ * @param {Object} options - The `display` options
216
+ * @returns {Promise<void>}
217
+ */
218
+ async function castResolved(resolved, options) {
219
+ const isPlaylistSource = resolved.kind === 'playlist';
220
+ const sourceLabel = isPlaylistSource
221
+ ? `${resolved.sourceType}: ${resolved.source}`
222
+ : resolved.source;
223
+ console.log(chalk_1.default.blue('\nDisplay on FF1\n'));
224
+ if (resolved.kind === 'media' && resolved.note) {
225
+ console.log(chalk_1.default.yellow(resolved.note));
226
+ }
227
+ if (!options.skipVerify && isPlaylistSource) {
228
+ console.log(chalk_1.default.cyan(`Verify playlist (${sourceLabel})`));
229
+ }
230
+ // A direct media/web URL is wrapped in an unsigned single-item playlist,
231
+ // so it must be signed before the verify gate; a loaded playlist is sent
232
+ // as-is. castPlaylist owns the sign → verify → send sequence.
233
+ // Resolve through getPlaylistConfig rather than reading config.playlist directly: it is the one
234
+ // place that knows a config value may still be the config.json.example placeholder, in which case a
235
+ // valid PLAYLIST_PRIVATE_KEY must win. Duplicating the `config || env` precedence here is what made
236
+ // this path sign with the template literal while every other signing path used the real key.
237
+ const privateKey = (0, config_1.getPlaylistConfig)().privateKey || undefined;
238
+ const result = await (0, playlist_cast_1.castPlaylist)(resolved.playlist, {
239
+ deviceName: options.device,
240
+ skipVerify: options.skipVerify,
241
+ requireSignature: resolved.kind === 'media',
242
+ signingKey: privateKey,
243
+ });
244
+ if (!result.success) {
245
+ if (result.stage === 'verify') {
246
+ (0, playlist_display_1.printPlaylistVerificationFailure)({ valid: false, error: result.error, details: result.details }, isPlaylistSource ? `source: ${sourceLabel}` : undefined);
247
+ }
248
+ else if (result.stage === 'sign') {
249
+ console.error(chalk_1.default.red(`\n${result.error}`));
250
+ }
251
+ else {
252
+ console.error(chalk_1.default.red('\nDisplay failed:'), result.error);
253
+ if (result.deviceDetails) {
254
+ console.error(chalk_1.default.dim(` Details: ${result.deviceDetails}`));
255
+ }
256
+ }
257
+ process.exit(1);
258
+ }
259
+ if (result.verified) {
260
+ console.log(chalk_1.default.green(result.signed ? '✓ Signed and verified\n' : '✓ Verified\n'));
261
+ }
262
+ console.log(chalk_1.default.green('✓ Displaying'));
263
+ if (result.deviceName) {
264
+ console.log(chalk_1.default.dim(` Device: ${result.deviceName}`));
265
+ }
266
+ if (result.device) {
267
+ console.log(chalk_1.default.dim(` Host: ${result.device}`));
268
+ }
269
+ console.log();
270
+ }