@feralfile/cli 3.0.0 → 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 (45) hide show
  1. package/README.md +20 -17
  2. package/config.json.example +1 -1
  3. package/dist/index.js +10 -2
  4. package/dist/package.json +5 -2
  5. package/dist/src/commands/build.js +1 -1
  6. package/dist/src/commands/device.js +84 -39
  7. package/dist/src/commands/enrich.js +2 -2
  8. package/dist/src/commands/find.js +71 -37
  9. package/dist/src/commands/helpers/device-control.js +937 -0
  10. package/dist/src/commands/helpers/hidden-alias-help.js +37 -0
  11. package/dist/src/commands/helpers/playlist-display.js +2 -15
  12. package/dist/src/commands/play.js +39 -26
  13. package/dist/src/commands/report.js +251 -0
  14. package/dist/src/commands/setup.js +1 -1
  15. package/dist/src/config.js +162 -6
  16. package/dist/src/utilities/control/codec.js +578 -0
  17. package/dist/src/utilities/control/control-errors.js +157 -0
  18. package/dist/src/utilities/control/controller-store.js +964 -0
  19. package/dist/src/utilities/control/jwk.js +141 -0
  20. package/dist/src/utilities/control/pairing-channel.js +52 -0
  21. package/dist/src/utilities/control/pairing.js +416 -0
  22. package/dist/src/utilities/control/registration.js +391 -0
  23. package/dist/src/utilities/control/relay-pairing-channel.js +191 -0
  24. package/dist/src/utilities/control/secure-transport.js +850 -0
  25. package/dist/src/utilities/control/strict-json.js +376 -0
  26. package/dist/src/utilities/device-network.js +129 -0
  27. package/dist/src/utilities/device-upsert.js +1 -1
  28. package/dist/src/utilities/ff1-compatibility.js +87 -11
  29. package/dist/src/utilities/ff1-device.js +161 -78
  30. package/dist/src/utilities/ff1-relayer.js +34 -14
  31. package/dist/src/utilities/handoff-client.js +23 -9
  32. package/dist/src/utilities/neort-marketplace.js +3 -3
  33. package/dist/src/utilities/playlist-builder.js +3 -3
  34. package/dist/src/utilities/playlist-cast.js +6 -6
  35. package/dist/src/utilities/playlist-source.js +7 -7
  36. package/dist/src/utilities/raster-client.js +1 -1
  37. package/dist/src/utilities/relay-client.js +774 -0
  38. package/dist/src/utilities/ssh-access.js +192 -58
  39. package/dist/src/utilities/support-report.js +511 -0
  40. package/dist/src/utilities/temporary-channel.js +139 -0
  41. package/docs/CONFIGURATION.md +270 -28
  42. package/docs/EXAMPLES.md +31 -31
  43. package/docs/PROJECT_SPEC.md +32 -19
  44. package/docs/README.md +113 -36
  45. package/package.json +5 -2
package/README.md CHANGED
@@ -4,7 +4,7 @@ A small Node.js CLI for building DP-1 playlists of digital art.
4
4
 
5
5
  **Runtime:** Node.js 22 or newer (matches CI and the `dp1-js` dependency). That engine floor is a **breaking** change if you previously used Node 18 or 20—check the **GitHub Release** for the version you move to; release authors follow `docs/RELEASING.md` so the notes stay explicit.
6
6
 
7
- ff-cli is a set of deterministic commands for building DP-1–conformant playlists and playing them on an Art Computer: schema validation, indexing, JSON‑LD, signing, and device delivery. There's no built-in chat — natural language belongs in your coding agent (Claude Code, Codex), which drives ff-cli through the `ff-control` skill (see [Use with Claude Code](#use-with-claude-code)). No LLM API key required.
7
+ ff-cli is a set of deterministic commands for building DP-1–conformant playlists and displaying them on an Art Computer: schema validation, indexing, JSON‑LD, signing, and device delivery. There's no built-in chat — natural language belongs in your coding agent (Claude Code, Codex), which drives ff-cli through the `ff-control` skill (see [Use with Claude Code](#use-with-claude-code)). No LLM API key required.
8
8
 
9
9
  ## Install
10
10
 
@@ -18,16 +18,16 @@ This installs a complete package (including `config.json.example` and the bundle
18
18
 
19
19
  ```bash
20
20
  npx @feralfile/cli setup
21
- npx @feralfile/cli find https://www.artblocks.io/collection/ringers-by-dmitri-cherniak --play
21
+ npx @feralfile/cli find https://www.artblocks.io/collection/ringers-by-dmitri-cherniak --display
22
22
  ```
23
23
 
24
24
  ## Quick Start
25
25
 
26
26
  ```bash
27
27
  ff-cli setup
28
- ff-cli find https://objkt.com/tokens/hicetnunc/111068 --play
29
- ff-cli play "https://example.com/video.mp4"
30
- ff-cli play "https://example.com/" # any web page
28
+ ff-cli find https://objkt.com/tokens/hicetnunc/111068 --display
29
+ ff-cli display "https://example.com/video.mp4"
30
+ ff-cli display "https://example.com/" # any web page
31
31
  ```
32
32
 
33
33
  If you need manual config actions instead of guided setup:
@@ -54,25 +54,27 @@ ff-cli setup -y --key "<private-key>" --role agent
54
54
 
55
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
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.
57
+ Without a terminal, `find` needs `-y` (it displays by default; add `--no-display` to only save). A question with no terminal to answer it exits 1 and names the flag.
58
58
 
59
59
  Device pairing is also scriptable on its own: `ff-cli device add --host http://<ip>:1111 --name <name>` skips mDNS discovery entirely.
60
60
 
61
+ On FF OS with secure control, an FF1 only accepts commands from controllers an owner added. `ff-cli device pair <name>` shows a QR code; an owner scans it with **Add controller** in the Feral File app, and ff-cli enrolls with the role they picked. Pairing needs an internet connection and an FF1 that already has an owner (ff-cli cannot be the first owner); `--resume` finishes a pairing an owner already approved, and `ff-cli device unpair <name>` revokes ff-cli on the FF1 before deleting its keys. FF1s without secure control pair through a one-time code ("Pair with ff-cli" in the app) that hands over relayer access; ff-cli picks the path from the FF1's status, and `--topic-handoff` forces the one-time code. Details: [docs/CONFIGURATION.md](./docs/CONFIGURATION.md#secure-control-pairing).
62
+
61
63
  ## Find an artwork
62
64
 
63
- `ff-cli find` resolves a URL, raw `chain:contract:tokenId`, or a wallet address into a playable DP-1 playlist. The focus is computational and generative art; PFP collections and pre-ERC-721 contracts have limits noted below.
65
+ `ff-cli find` resolves a URL, raw `chain:contract:tokenId`, or a wallet address into a DP-1 playlist ready to display. The focus is computational and generative art; PFP collections and pre-ERC-721 contracts have limits noted below.
64
66
 
65
67
  ```bash
66
- # Most satisfying: paste a URL, play it on your FF1
67
- ff-cli find https://www.artblocks.io/collection/ringers-by-dmitri-cherniak --play
68
+ # Most satisfying: paste a URL, display it on your FF1
69
+ ff-cli find https://www.artblocks.io/collection/ringers-by-dmitri-cherniak --display
68
70
 
69
71
  # Tezos / hic et nunc via Objkt (the alias resolves to a KT1 contract)
70
- ff-cli find https://objkt.com/tokens/hicetnunc/111068 --play
72
+ ff-cli find https://objkt.com/tokens/hicetnunc/111068 --display
71
73
 
72
74
  # Feral File artwork (public id can be hex or numeric — both forms resolve via /api/artworks)
73
- ff-cli find https://feralfile.com/exhibitions/artwork/f0240e04d64717e319584957f6a83954b029254ad1260b6320472ea8c0c5b1cf --play
75
+ ff-cli find https://feralfile.com/exhibitions/artwork/f0240e04d64717e319584957f6a83954b029254ad1260b6320472ea8c0c5b1cf --display
74
76
 
75
- # Save without playing
77
+ # Save without displaying
76
78
  ff-cli find ethereum:0xababababab20053426ad1c782de9ea8444358070:5001410 --output send-receive.json
77
79
  ```
78
80
 
@@ -83,7 +85,7 @@ Sources: Art Blocks, Objkt, fxhash (canonical `/gentk/...`, live `/iteration/{sl
83
85
  - **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.
84
86
  - **Mainstream PFPs** (Azuki, BAYC, Pudgy Penguins) build a single-item playlist instead of a series — Raster's curation is effectively the series allowlist.
85
87
  - **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).
88
+ - **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 displays the version Raster recorded), and some Feral File works point at a retired image host and display black. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md#tokensource).
87
89
 
88
90
  ## Use with Claude Code
89
91
 
@@ -94,7 +96,7 @@ d="$(mktemp -d)" && git clone --depth=1 https://github.com/feral-file/ff-cli "$d
94
96
  && mkdir -p ~/.claude/skills && cp -r "$d/skills/ff-control" ~/.claude/skills/
95
97
  ```
96
98
 
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).
99
+ Claude Code will surface it when you ask to build a playlist, display 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).
98
100
 
99
101
  ## Use with Codex
100
102
 
@@ -105,15 +107,15 @@ d="$(mktemp -d)" && git clone --depth=1 https://github.com/feral-file/ff-cli "$d
105
107
  && mkdir -p ~/.codex/skills && cp -r "$d/skills/ff-control" ~/.codex/skills/
106
108
  ```
107
109
 
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).
110
+ Codex will surface it when you ask to build a playlist, display 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
111
 
110
112
  ## Dev Quick Start
111
113
 
112
114
  ```bash
113
115
  npm ci
114
116
  npm run dev -- setup
115
- npm run dev -- find https://objkt.com/tokens/hicetnunc/111068 --play
116
- npm run dev -- play "https://example.com/video.mp4"
117
+ npm run dev -- find https://objkt.com/tokens/hicetnunc/111068 --display
118
+ npm run dev -- display "https://example.com/video.mp4"
117
119
  ```
118
120
 
119
121
  ## Documentation
@@ -122,6 +124,7 @@ npm run dev -- play "https://example.com/video.mp4"
122
124
  - Configuration: `./docs/CONFIGURATION.md`
123
125
  - Examples: `./docs/EXAMPLES.md`
124
126
  - SSH access: `ff-cli ssh enable|disable` in `./docs/README.md`
127
+ - Problem reports to Feral File support: `ff-cli report` in `./docs/README.md`
125
128
 
126
129
  ## Verification
127
130
 
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "feedServers": [
9
9
  {
10
- "baseUrl": "https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1"
10
+ "baseUrl": "https://feed.feralfile.com/api/v1"
11
11
  },
12
12
  {
13
13
  "baseUrl": "http://localhost:8787"
package/dist/index.js CHANGED
@@ -34,6 +34,9 @@ const enrich_1 = require("./src/commands/enrich");
34
34
  const config_1 = require("./src/commands/config");
35
35
  const ssh_1 = require("./src/commands/ssh");
36
36
  const device_1 = require("./src/commands/device");
37
+ const report_1 = require("./src/commands/report");
38
+ const hidden_alias_help_1 = require("./src/commands/helpers/hidden-alias-help");
39
+ const relay_pairing_channel_1 = require("./src/utilities/control/relay-pairing-channel");
37
40
  // Load version from package.json. Try the built location first
38
41
  // (dist/index.js -> ../package.json) and fall back to dev (./package.json).
39
42
  let packageJsonPath = (0, path_1.resolve)((0, path_1.dirname)(__filename), '..', 'package.json');
@@ -49,13 +52,15 @@ program
49
52
  .name('ff-cli')
50
53
  .description(packageDescription)
51
54
  .version(packageVersion)
52
- .addHelpText('after', `\nQuick start:\n 1) ff-cli setup\n 2) ff-cli play <url-or-playlist>\n\nDocs: https://github.com/feral-file/ff-cli\n`);
55
+ // Lists aliased commands by their name only (`display`, never `display|play`); see hiddenAliasHelp.
56
+ .configureHelp(hidden_alias_help_1.hiddenAliasHelp)
57
+ .addHelpText('after', `\nQuick start:\n 1) ff-cli setup\n 2) ff-cli display <url-or-playlist>\n\nDocs: https://github.com/feral-file/ff-cli\n`);
53
58
  program.addCommand(setup_1.setupCommand);
54
59
  program.addCommand(status_1.statusCommand);
55
60
  program.addCommand(validate_1.verifyCommand);
56
61
  program.addCommand(validate_1.validateCommand);
57
62
  program.addCommand(sign_1.signCommand);
58
- program.addCommand(play_1.playCommand);
63
+ program.addCommand(play_1.displayCommand);
59
64
  program.addCommand(publish_1.publishCommand);
60
65
  program.addCommand(unpublish_1.unpublishCommand);
61
66
  program.addCommand(fetch_1.fetchCommand);
@@ -65,4 +70,7 @@ program.addCommand(build_1.buildCommand);
65
70
  program.addCommand(config_1.configCommand);
66
71
  program.addCommand(ssh_1.sshCommand);
67
72
  program.addCommand(device_1.deviceCommand);
73
+ program.addCommand(report_1.reportCommand);
74
+ // `device pair` joins its pairing channel on relay v2 when an endpoint is set.
75
+ (0, relay_pairing_channel_1.installRelayPairingChannel)();
68
76
  program.parse();
package/dist/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@feralfile/cli",
3
- "version": "3.0.0",
4
- "description": "CLI for building, signing, and playing DP-1 playlists of digital art on Feral File devices",
3
+ "version": "4.0.0",
4
+ "description": "CLI for building, signing, and displaying DP-1 playlists of digital art on Feral File devices",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/feral-file/ff-cli.git"
@@ -68,10 +68,13 @@
68
68
  "dp1-js": "2.4.1",
69
69
  "ff1": "^1.0.0",
70
70
  "fuzzysort": "^3.1.0",
71
+ "jose": "6.2.12",
72
+ "qrcode-terminal": "0.12.0",
71
73
  "viem": "^2.56.3"
72
74
  },
73
75
  "devDependencies": {
74
76
  "@types/node": "^24.7.1",
77
+ "@types/qrcode-terminal": "0.12.2",
75
78
  "@typescript-eslint/eslint-plugin": "^8.46.0",
76
79
  "@typescript-eslint/parser": "^8.46.0",
77
80
  "esbuild": "^0.25.10",
@@ -30,7 +30,7 @@ Requirement types:
30
30
 
31
31
  playlistSettings (all optional):
32
32
  title, durationPerItem (seconds), preserveOrder (default true; false shuffles),
33
- deviceName (play on that FF1 after building)
33
+ deviceName (display on that FF1 after building)
34
34
 
35
35
  Exit status: 1 when a requirement finds nothing or fails, when named tokenIds
36
36
  do not all resolve, or when the send to deviceName fails. The playlist is still
@@ -15,57 +15,71 @@ const device_rename_1 = require("../utilities/device-rename");
15
15
  const config_files_1 = require("./helpers/config-files");
16
16
  const device_discovery_1 = require("./helpers/device-discovery");
17
17
  const prompt_1 = require("./helpers/prompt");
18
- const ff1_compatibility_1 = require("../utilities/ff1-compatibility");
19
- const handoff_client_1 = require("../utilities/handoff-client");
20
- const topic_store_1 = require("../utilities/topic-store");
18
+ const device_control_1 = require("./helpers/device-control");
21
19
  const device_removal_1 = require("./helpers/device-removal");
20
+ const http_1 = require("../utilities/http");
21
+ const support_report_1 = require("../utilities/support-report");
22
+ const pairing_1 = require("../utilities/control/pairing");
23
+ /** DEVICE_ID_STATUS_TIMEOUT_MS bounds the status read that names a manually added FF1 at an
24
+ * address; a `.local` name gets the longer lanReadTimeoutMs deadline. */
25
+ const DEVICE_ID_STATUS_TIMEOUT_MS = 3_000;
22
26
  const deviceCommand = new commander_1.Command('device').description('Manage configured FF1 devices');
23
27
  exports.deviceCommand = deviceCommand;
24
28
  deviceCommand
25
29
  .command('pair')
26
- .description('Securely receive relayer access for a configured FF1 from the mobile app')
30
+ .description('Pair ff-cli with a configured FF1: on secure control, show a code an owner scans with "Add controller" in the Feral File app (needs internet and an owner); otherwise receive relayer access through a one-time code')
31
+ .argument('[name]', 'Configured device name (uses the default device when omitted)')
32
+ .option('--label <label>', "Name the owner sees when adding ff-cli (default: this computer's hostname)")
33
+ .option('--timeout <seconds>', 'How long to wait for an owner, 10-600 (default: 300)')
34
+ .option('--resume', 'Finish a pairing an owner already approved, without a new code')
35
+ .option('-y, --yes', 'Move a corrupt controller file aside without asking')
36
+ .option('--topic-handoff', 'Use the one-time code and "Pair with ff-cli" in the app (FF OS without secure control); chosen automatically when the FF1 reports no secure control')
37
+ .action(async (name, options) => {
38
+ await runControlAction('Pairing failed:', () => (0, device_control_1.runPair)(name, options));
39
+ });
40
+ deviceCommand
41
+ .command('controllers')
42
+ .description('List the controllers paired with an FF1 (owner only)')
27
43
  .argument('[name]', 'Configured device name (uses the default device when omitted)')
28
44
  .action(async (name) => {
29
- let receiver;
45
+ await runControlAction('Error:', () => (0, device_control_1.runControllers)(name));
46
+ });
47
+ deviceCommand
48
+ .command('revoke')
49
+ .description("Revoke a controller's access to an FF1, or deny its pending request (owner only)")
50
+ .argument('<controller-id>', 'Controller ID shown by `ff-cli device controllers`')
51
+ .argument('[name]', 'Configured device name (uses the default device when omitted)')
52
+ .action(async (ctl, name) => {
53
+ await runControlAction('Error:', () => (0, device_control_1.runRevoke)(ctl, name));
54
+ });
55
+ deviceCommand
56
+ .command('unpair')
57
+ .description("Revoke ff-cli's access on an FF1 (best effort) and delete its controller keys")
58
+ .argument('[name]', 'Configured device name (uses the default device when omitted)')
59
+ .option('-y, --yes', 'Delete the keys without asking')
60
+ .action(async (name, options) => {
61
+ await runControlAction('Error:', () => (0, device_control_1.runUnpair)(name, options));
62
+ });
63
+ /**
64
+ * runControlAction runs a pairing/controller action and prints a failure as
65
+ * an error line plus its follow-up, setting a non-zero exit code.
66
+ *
67
+ * @param prefix - Error prefix
68
+ * @param action - Action to run
69
+ */
70
+ async function runControlAction(prefix, action) {
30
71
  try {
31
- const selected = (0, ff1_compatibility_1.resolveConfiguredDevice)(name);
32
- if (!selected.success || !selected.device) {
33
- throw new Error(selected.error || 'FF1 device is not configured correctly');
34
- }
35
- const device = selected.device;
36
- if (!device.id) {
37
- throw new Error(`Device "${device.name || device.host}" has no stable device ID. Re-add it with ff-cli device add before pairing.`);
38
- }
39
- receiver = await handoff_client_1.TopicHandoffReceiver.create();
40
- console.log(chalk_1.default.blue('\nPair ff-cli with Feral File mobile\n'));
41
- console.log(` Device: ${chalk_1.default.bold(device.name || device.id)}`);
42
- console.log(` Device ID: ${chalk_1.default.dim(device.id)}`);
43
- console.log("\nIn the mobile app, open this Art Computer's options and choose");
44
- console.log(chalk_1.default.bold('“Pair with ff-cli”'), 'then enter:');
45
- console.log(`\n ${chalk_1.default.bold(receiver.shortCode)}\n`);
46
- console.log('Before sharing, confirm the mobile security check matches:');
47
- console.log(`\n ${chalk_1.default.bold(receiver.verificationCode)}\n`);
48
- console.log(chalk_1.default.dim(`This one-time code expires at ${receiver.expiresAt}. Waiting...`));
49
- const payload = await receiver.receiveTopic({ expectedDeviceId: device.id });
50
- (0, topic_store_1.storeTopicId)(device.id, payload.topicId);
51
- console.log(chalk_1.default.green('\n✓ Relayer access stored in the operating system secure storage.\n'));
72
+ await action();
52
73
  }
53
74
  catch (error) {
54
- console.error(chalk_1.default.red('\nPairing failed:'), error.message);
55
- process.exitCode = 1;
56
- }
57
- finally {
58
- if (receiver) {
59
- try {
60
- await receiver.close();
61
- }
62
- catch {
63
- // The channel is short-lived and expires automatically. A close
64
- // failure must not erase or misreport an already stored credential.
65
- }
75
+ console.error(chalk_1.default.red(`\n${prefix}`), error.message);
76
+ if (error instanceof device_control_1.CommandFailure && error.details) {
77
+ console.error(chalk_1.default.dim(error.details));
66
78
  }
79
+ console.error();
80
+ process.exitCode = 1;
67
81
  }
68
- });
82
+ }
69
83
  deviceCommand
70
84
  .command('list')
71
85
  .description('List all configured FF1 devices')
@@ -231,6 +245,37 @@ deviceCommand
231
245
  return;
232
246
  }
233
247
  }
248
+ // A manual host has no discovered ID, and pairing needs one: without it
249
+ // `device pair` sent the user back here, which stored none again. The
250
+ // LAN status names the device; like an mDNS record it is unauthenticated
251
+ // and only labels the entry. It never replaces a stored ID: the rows this
252
+ // update can land on (the matched one, else upsertDevice's same-host or
253
+ // same-name row) are left alone when one already has an ID, and an ID
254
+ // another row already holds is not recorded here (that would merge the
255
+ // two rows or orphan a controller record keyed by the old ID).
256
+ if (options.host && !discoveredId) {
257
+ const targets = existingIndex !== -1
258
+ ? [existingDevices[existingIndex]]
259
+ : existingDevices.filter((d) => d.host === hostValue || d.name === deviceName);
260
+ if (!targets.some((d) => d.id)) {
261
+ const { device: status } = await (0, support_report_1.readDeviceStatus)({ name: deviceName, host: hostValue, apiKey: targets[0]?.apiKey }, (input, init) => (0, http_1.fetchWithTimeout)(input, init ?? {}, (0, pairing_1.lanReadTimeoutMs)(hostValue, DEVICE_ID_STATUS_TIMEOUT_MS)));
262
+ const statusId = status.device_id?.trim().toUpperCase();
263
+ const holder = statusId
264
+ ? existingDevices.find((d) => !targets.includes(d) && d.id?.trim().toUpperCase() === statusId)
265
+ : undefined;
266
+ if (!statusId || !/^FF1-[A-Z0-9]{4,}$/.test(statusId)) {
267
+ console.log(chalk_1.default.dim('Could not read a device ID from the FF1; pairing needs --id FF1-XXXXXXXX.'));
268
+ }
269
+ else if (holder) {
270
+ console.log(chalk_1.default.yellow(`The FF1 at ${hostValue} reports ${statusId}, which is already configured as "${holder.name || holder.host}". ` +
271
+ 'Not recording it here: remove that device first, or pass --id.'));
272
+ }
273
+ else {
274
+ discoveredId = statusId;
275
+ console.log(chalk_1.default.dim(`Device ID ${statusId} from the FF1's LAN status.`));
276
+ }
277
+ }
278
+ }
234
279
  const result = (0, device_upsert_1.upsertDevice)(existingDevices, { name: deviceName, host: hostValue, id: discoveredId, addresses: discoveredAddresses }, existingIndex !== -1 ? existingIndex : undefined);
235
280
  console.log(chalk_1.default.green(`\n${result.updated ? 'Updated' : 'Added'} device: ${deviceName}`));
236
281
  config.ff1Devices = { devices: result.devices };
@@ -272,7 +272,7 @@ async function syncDirectory(directory, filesystem) {
272
272
  *
273
273
  * Enrichment refuses to work on a document DP-1 rejects, and refuses to write
274
274
  * one. Writing an invalid playlist would replace the operator's file with
275
- * something sign, publish, and play all reject later, having reported success.
275
+ * something sign, publish, and display all reject later, having reported success.
276
276
  */
277
277
  function reportInvalid(stage, result) {
278
278
  console.error(chalk_1.default.red(`\n${stage}`));
@@ -404,7 +404,7 @@ exports.enrichCommand = new commander_1.Command('enrich')
404
404
  // fails at the device, which is the worst place to discover it.
405
405
  if (result.signatureInvalidated) {
406
406
  console.log(chalk_1.default.yellow('\n Signature removed — the document changed.'));
407
- console.log(chalk_1.default.dim(` Re-sign before playing: ff-cli sign ${destination}`));
407
+ console.log(chalk_1.default.dim(` Re-sign before displaying: ff-cli sign ${destination}`));
408
408
  }
409
409
  console.log();
410
410
  }
@@ -38,32 +38,53 @@ const RASTER_PAGE_SIZE = 100;
38
38
  /**
39
39
  * FIND_NON_INTERACTIVE_HINT is what `find` tells a caller without a terminal
40
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.
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
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.';
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
45
  /**
46
46
  * playAPageHint points a caller who handed `find` a page it cannot resolve to
47
47
  * the command that shows a page as it is.
48
48
  *
49
49
  * @param {string} input - The URL the caller gave `find`
50
- * @returns {string} One line naming `ff-cli play --web`
50
+ * @returns {string} One line naming `ff-cli display --web`
51
51
  */
52
52
  function playAPageHint(input) {
53
- return `To show this page itself on your FF1, run: ff-cli play --web "${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;
54
72
  }
55
73
  exports.findCommand = new commander_1.Command('find')
56
74
  .description('Find an artwork on the web and build a DP-1 playlist')
57
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`)')
58
76
  .option('-o, --output <path>', 'Save the playlist to this file (default: ./<slug>.json)')
59
77
  .option('-l, --limit <n>', 'Max tokens to include from the series (default: all)')
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')
62
- .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)')
63
84
  .option('--publish', 'Publish the playlist to a configured feed server')
64
85
  .option('-s, --server <index>', 'Feed server index (used with --publish)')
65
- .option('-y, --yes', 'Skip interactive prompts. Without --output or --publish, -y plays the playlist on a device; add --no-play to skip casting')
66
- .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`)')
67
88
  .action(async (input, options) => {
68
89
  try {
69
90
  console.log(chalk_1.default.blue('\nFind on Feral File CLI\n'));
@@ -73,10 +94,10 @@ exports.findCommand = new commander_1.Command('find')
73
94
  console.error(chalk_1.default.dim('Supported URLs: Objkt, fxhash, Art Blocks, OpenSea, SuperRare, Feral File, Neort, Verse, Raster. ' +
74
95
  'Or: `ethereum:{contract}:{tokenId}`, `tezos:{contract}:{tokenId}`, ' +
75
96
  'or a wallet address (`0x...` / `tz1.../tz2.../tz3...`).'));
76
- // A plain web page or media URL is not an artwork lookup at all; `play`
97
+ // A plain web page or media URL is not an artwork lookup at all; `display`
77
98
  // casts those directly, so name it rather than leave the caller stuck.
78
99
  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}"`));
100
+ console.error(chalk_1.default.dim(`To display a web page or media URL as it is, run: ff-cli display "${input}"`));
80
101
  }
81
102
  process.exit(1);
82
103
  }
@@ -87,7 +108,7 @@ exports.findCommand = new commander_1.Command('find')
87
108
  return;
88
109
  }
89
110
  console.error(chalk_1.default.red(parsed.reason));
90
- // `play` sends unrecognized pages on these hosts here, so the way out
111
+ // `display` sends unrecognized pages on these hosts here, so the way out
91
112
  // of that loop has to be printed on this side too.
92
113
  console.error(chalk_1.default.dim(playAPageHint(input)));
93
114
  process.exit(1);
@@ -282,7 +303,7 @@ async function resolveTarget(input, parsed, skipPrompt, limitOption) {
282
303
  // A wallet address reads naturally as "what this wallet owns", and this
283
304
  // path builds something else: one work by the artist at the address. Say
284
305
  // 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. ' +
306
+ console.log(chalk_1.default.dim('find on an address displays one work by the artist at that address. ' +
286
307
  'For what the wallet owns, use build with a query_address requirement (ff-cli build --help).'));
287
308
  const picked = await pickFromCatalog(catalog, skipPrompt);
288
309
  return {
@@ -323,11 +344,11 @@ async function resolveTokenListInput(input, limit, fallbackTitle = 'Resolved sou
323
344
  }
324
345
  /**
325
346
  * Resolve on-chain coords to a target. If Raster doesn't index this token,
326
- * 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.
327
348
  *
328
349
  * Raster being unreachable gets the same fallback: it only enriches the find
329
350
  * (series enumeration, artist labels) — the coords in hand are enough to
330
- * 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
331
352
  * dependency must not kill the whole command (#97).
332
353
  */
333
354
  async function resolveCoords(coords) {
@@ -480,20 +501,23 @@ async function confirmMakePlaylist(count, hasMore) {
480
501
  /**
481
502
  * Decide which post-build actions to run.
482
503
  *
483
- * Precedence: explicit flags (--play / --publish) win and may combine. If no
484
- * flag is set, fall back to --yes default (Play) or an interactive 3-way
485
- * 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.
486
507
  *
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.
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'.
492
515
  */
493
516
  async function decideActions(options) {
494
517
  const flagActions = [];
495
- const noPlay = options.play === false;
496
- if (options.play) {
518
+ const display = displayIntent(options);
519
+ const noDisplay = display === false;
520
+ if (display) {
497
521
  flagActions.push('play');
498
522
  }
499
523
  if (options.publish) {
@@ -503,24 +527,34 @@ async function decideActions(options) {
503
527
  return flagActions;
504
528
  }
505
529
  // --output alone (with or without --yes) means "save mode" — the save was the action.
506
- // --no-play likewise means the save was the action; never prompt or default to Play.
507
- 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) {
508
532
  return [];
509
533
  }
510
- // --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).
511
535
  if (options.yes) {
512
536
  return ['play'];
513
537
  }
514
538
  return [await promptNextAction()];
515
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
+ */
516
549
  async function promptNextAction() {
517
- const prompt = (0, prompt_1.createPrompt)('The playlist is saved. Pass --play to cast it, or --publish to publish it.');
518
- 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): '))
519
552
  .trim()
520
553
  .toLowerCase();
521
554
  prompt.close();
522
555
  console.log();
523
- if (!answer || answer === 'p' || answer === 'play' || answer === 'y' || answer === 'yes') {
556
+ const displayAnswers = ['', 'd', 'display', 'p', 'play', 'y', 'yes'];
557
+ if (displayAnswers.includes(answer)) {
524
558
  return 'play';
525
559
  }
526
560
  if (answer === 's' || answer === 'save' || answer === 'n' || answer === 'no') {
@@ -529,10 +563,10 @@ async function promptNextAction() {
529
563
  if (answer === 'l' || answer === 'pub' || answer === 'publish') {
530
564
  return 'publish';
531
565
  }
532
- throw new Error(`Unrecognized choice: "${answer}". Use P, S, or L.`);
566
+ throw new Error(`Unrecognized choice: "${answer}". Use D, S, or L.`);
533
567
  }
534
568
  async function doPlay(playlist, deviceName, skipVerify) {
535
- // 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
536
570
  // helper. `find`'s playlists are already signed by buildDP1Playlist (when a
537
571
  // key is configured), so no signing is requested here — an unsigned one
538
572
  // fails the verify gate with an actionable message instead of reaching the
@@ -553,7 +587,7 @@ async function doPlay(playlist, deviceName, skipVerify) {
553
587
  'or pass --skip-verify to bypass.'));
554
588
  }
555
589
  else {
556
- console.error(chalk_1.default.red('Play failed:'), result.error);
590
+ console.error(chalk_1.default.red('Display failed:'), result.error);
557
591
  if (result.deviceDetails) {
558
592
  console.error(chalk_1.default.dim(` Details: ${result.deviceDetails}`));
559
593
  }
@@ -564,8 +598,8 @@ async function doPlay(playlist, deviceName, skipVerify) {
564
598
  if (result.verified) {
565
599
  console.log(chalk_1.default.green('✓ Verified\n'));
566
600
  }
567
- console.log(chalk_1.default.blue('Play on FF1'));
568
- 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'));
569
603
  if (result.deviceName) {
570
604
  console.log(chalk_1.default.dim(` Device: ${result.deviceName}`));
571
605
  }