@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
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,15 +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" --skip-verify
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
30
31
  ```
31
32
 
32
33
  If you need manual config actions instead of guided setup:
@@ -41,9 +42,9 @@ ff-cli config validate
41
42
  `ff-cli setup` is fully scriptable — no TTY required. It runs non-interactively when stdin is not a terminal, or when you pass `-y/--non-interactive`, and takes flags for every step:
42
43
 
43
44
  ```bash
44
- # Generate a signing key and register a device in one shot, no prompts
45
+ # Register a device, no prompts. Generates a signing key only when none is configured,
46
+ # and keeps one that is.
45
47
  ff-cli setup --non-interactive \
46
- --generate-key \
47
48
  --device-host http://192.168.1.50:1111 \
48
49
  --device-name "Living Room"
49
50
 
@@ -51,23 +52,29 @@ ff-cli setup --non-interactive \
51
52
  ff-cli setup -y --key "<private-key>" --role agent
52
53
  ```
53
54
 
55
+ `--generate-key`, `--key`, and `--key-file` refuse to replace a signing key that is already configured, because that key owns the playlists you published. Add `--force` only when you mean to replace it.
56
+
57
+ Without a terminal, `find` needs `-y` (it displays by default; add `--no-display` to only save). A question with no terminal to answer it exits 1 and names the flag.
58
+
54
59
  Device pairing is also scriptable on its own: `ff-cli device add --host http://<ip>:1111 --name <name>` skips mDNS discovery entirely.
55
60
 
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
+
56
63
  ## Find an artwork
57
64
 
58
- `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.
59
66
 
60
67
  ```bash
61
- # Most satisfying: paste a URL, play it on your FF1
62
- 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
63
70
 
64
71
  # Tezos / hic et nunc via Objkt (the alias resolves to a KT1 contract)
65
- ff-cli find https://objkt.com/tokens/hicetnunc/111068 --play
72
+ ff-cli find https://objkt.com/tokens/hicetnunc/111068 --display
66
73
 
67
74
  # Feral File artwork (public id can be hex or numeric — both forms resolve via /api/artworks)
68
- ff-cli find https://feralfile.com/exhibitions/artwork/f0240e04d64717e319584957f6a83954b029254ad1260b6320472ea8c0c5b1cf --play
75
+ ff-cli find https://feralfile.com/exhibitions/artwork/f0240e04d64717e319584957f6a83954b029254ad1260b6320472ea8c0c5b1cf --display
69
76
 
70
- # Save without playing
77
+ # Save without displaying
71
78
  ff-cli find ethereum:0xababababab20053426ad1c782de9ea8444358070:5001410 --output send-receive.json
72
79
  ```
73
80
 
@@ -75,48 +82,49 @@ Sources: Art Blocks, Objkt, fxhash (canonical `/gentk/...`, live `/iteration/{sl
75
82
 
76
83
  **Known limitations**
77
84
 
78
- - **CryptoPunks (original)** don't index — the contract predates ERC-721 ([ff-indexer-v2#83](https://github.com/feral-file/ff-indexer-v2/issues/83)).
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.
79
86
  - **Mainstream PFPs** (Azuki, BAYC, Pudgy Penguins) build a single-item playlist instead of a series — Raster's curation is effectively the series allowlist.
80
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`.
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).
81
89
 
82
90
  ## Use with Claude Code
83
91
 
84
92
  ff-cli ships a [Claude Code](https://docs.claude.com/en/docs/claude-code) skill at `skills/ff-control/SKILL.md`. Install it once:
85
93
 
86
94
  ```bash
87
- git clone --depth=1 https://github.com/feral-file/ff-cli /tmp/ff-cli \
88
- && mkdir -p ~/.claude/skills \
89
- && cp -r /tmp/ff-cli/skills/ff-control ~/.claude/skills/
95
+ d="$(mktemp -d)" && git clone --depth=1 https://github.com/feral-file/ff-cli "$d" \
96
+ && mkdir -p ~/.claude/skills && cp -r "$d/skills/ff-control" ~/.claude/skills/
90
97
  ```
91
98
 
92
- 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).
93
100
 
94
101
  ## Use with Codex
95
102
 
96
103
  ff-cli ships the same `ff-control` skill in Codex's native skill format. Install it once:
97
104
 
98
105
  ```bash
99
- git clone --depth=1 https://github.com/feral-file/ff-cli /tmp/ff-cli \
100
- && mkdir -p ~/.codex/skills \
101
- && cp -r /tmp/ff-cli/skills/ff-control ~/.codex/skills/
106
+ d="$(mktemp -d)" && git clone --depth=1 https://github.com/feral-file/ff-cli "$d" \
107
+ && mkdir -p ~/.codex/skills && cp -r "$d/skills/ff-control" ~/.codex/skills/
102
108
  ```
103
109
 
104
- 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).
105
111
 
106
112
  ## Dev Quick Start
107
113
 
108
114
  ```bash
109
115
  npm ci
110
116
  npm run dev -- setup
111
- npm run dev -- find https://objkt.com/tokens/hicetnunc/111068 --play
112
- npm run dev -- play "https://example.com/video.mp4" --skip-verify
117
+ npm run dev -- find https://objkt.com/tokens/hicetnunc/111068 --display
118
+ npm run dev -- display "https://example.com/video.mp4"
113
119
  ```
120
+
114
121
  ## Documentation
115
122
 
116
123
  - Getting started and usage: `./docs/README.md`
117
124
  - Configuration: `./docs/CONFIGURATION.md`
118
125
  - Examples: `./docs/EXAMPLES.md`
119
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`
120
128
 
121
129
  ## Verification
122
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"
@@ -18,6 +18,11 @@
18
18
  "role": "curator",
19
19
  "curatorName": "YOUR_CURATOR_NAME"
20
20
  },
21
+ "tokenSource": "indexer",
22
+ "ipfsGateways": {
23
+ "feralFile": "https://ipfs.feralfile.com/ipfs/",
24
+ "default": "https://ipfs.filebase.io/ipfs/"
25
+ },
21
26
  "ff1Relayer": {
22
27
  "baseUrl": "https://tv-cast-coordination.autonomy-system.workers.dev"
23
28
  },
package/dist/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": "2.10.1",
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",
@@ -8,11 +8,43 @@ const commander_1 = require("commander");
8
8
  const chalk_1 = __importDefault(require("chalk"));
9
9
  const fs_1 = require("fs");
10
10
  const main_1 = require("../main");
11
+ /**
12
+ * BUILD_PARAMS_HELP documents the params file in `build --help`.
13
+ *
14
+ * The schema used to live only in docs/README.md and the agent skill, so a
15
+ * caller working from `--help` (the first thing an agent reads) had to guess
16
+ * requirement types. Keep it in step with validateRequirements in src/main.ts.
17
+ */
18
+ const BUILD_PARAMS_HELP = `
19
+ Params (JSON file, or piped on stdin):
20
+ {"requirements": [ ...one or more... ], "playlistSettings": { ... }}
21
+
22
+ Requirement types:
23
+ query_address What a wallet owns. ownerAddress (0x… or tz…), optional quantity (number up to 100, or "all").
24
+ {"type":"query_address","ownerAddress":"0x…","quantity":5}
25
+ build_playlist Tokens from a contract. blockchain ("ethereum" or "tezos"), contractAddress, tokenIds.
26
+ {"type":"build_playlist","blockchain":"ethereum","contractAddress":"0x…","tokenIds":["1","2"]}
27
+ fetch_feed A feed playlist by title. playlistName, optional quantity.
28
+ {"type":"fetch_feed","playlistName":"Unsupervised","quantity":3}
29
+ feral_file_artwork One Feral File artwork. artworkId (public id or /exhibitions/artwork/{id} URL).
30
+
31
+ playlistSettings (all optional):
32
+ title, durationPerItem (seconds), preserveOrder (default true; false shuffles),
33
+ deviceName (display on that FF1 after building)
34
+
35
+ Exit status: 1 when a requirement finds nothing or fails, when named tokenIds
36
+ do not all resolve, or when the send to deviceName fails. The playlist is still
37
+ saved when anything was collected.
38
+
39
+ Example:
40
+ echo '{"requirements":[{"type":"query_address","ownerAddress":"0x…","quantity":5}],"playlistSettings":{"title":"My collection"}}' | ff-cli build -o wallet.json
41
+ `;
11
42
  exports.buildCommand = new commander_1.Command('build')
12
43
  .description('Build playlist from structured parameters (JSON file or stdin)')
13
44
  .argument('[params-file]', 'Path to JSON parameters file (or use stdin)')
14
45
  .option('-o, --output <filename>', 'Output filename for the playlist', 'playlist.json')
15
46
  .option('-v, --verbose', 'Show detailed output', false)
47
+ .addHelpText('after', BUILD_PARAMS_HELP)
16
48
  .action(async (paramsFile, options) => {
17
49
  try {
18
50
  let params;
@@ -58,6 +90,14 @@ exports.buildCommand = new commander_1.Command('build')
58
90
  console.log(chalk_1.default.dim(` Items: ${result.playlist.items?.length || 0}`));
59
91
  console.log(chalk_1.default.dim(` Output: ${options.output}\n`));
60
92
  }
93
+ const failures = result?.failures ?? [];
94
+ if (failures.length > 0) {
95
+ console.error(chalk_1.default.red('Not everything you asked for happened:'));
96
+ for (const failure of failures) {
97
+ console.error(chalk_1.default.red(` ${failure}`));
98
+ }
99
+ process.exit(1);
100
+ }
61
101
  }
62
102
  catch (error) {
63
103
  console.error(chalk_1.default.red('\nError:'), error.message);
@@ -26,6 +26,7 @@ exports.configCommand = new commander_1.Command('config')
26
26
  const config = (0, config_1.getConfig)();
27
27
  console.log(chalk_1.default.blue('\nCurrent configuration\n'));
28
28
  console.log(chalk_1.default.bold('Default duration:'), chalk_1.default.white(config.defaultDuration + 's'));
29
+ console.log(chalk_1.default.bold('Token source:'), chalk_1.default.white((0, config_1.getTokenSourceConfig)().source));
29
30
  // The effective list, numbered. `-s <index>` on publish, fetch, replace
30
31
  // and unpublish is an index into exactly this list — whichever of
31
32
  // config.feedServers, the legacy feed.baseURLs, FEED_BASE_URLS, or the
@@ -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')
@@ -120,7 +134,7 @@ deviceCommand
120
134
  let prompt = null;
121
135
  const ask = async (question) => {
122
136
  if (!prompt) {
123
- prompt = (0, prompt_1.createPrompt)();
137
+ prompt = (0, prompt_1.createPrompt)('Pass --host http://<device-ip>:1111 and --name <name> to add or update a device without questions.');
124
138
  }
125
139
  return prompt.ask(question);
126
140
  };
@@ -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 };
@@ -18,15 +18,14 @@ const playlist_verifier_1 = require("../utilities/playlist-verifier");
18
18
  // distinct output here, and treating it as in-place makes the stale-input guard below refuse a write
19
19
  // that was never unsafe. The signer's direct-write path needs the opposite predicate; see same-file.ts.
20
20
  const same_file_1 = require("../utilities/same-file");
21
- // eslint-disable-next-line @typescript-eslint/no-require-imports
22
- const { resolveTokenBatch } = require('../utilities/nft-indexer');
21
+ const token_source_1 = require("../utilities/token-source");
23
22
  /**
24
23
  * writePlaylistAtomically replaces a file only once the new bytes are safely on
25
24
  * disk.
26
25
  *
27
26
  * The default destination is the input file, and a plain write truncates it
28
- * first. Enrichment runs after a lookup that can take minutes while the indexer
29
- * warms tokens, so the window between truncation and a complete write is a
27
+ * first. Enrichment runs after a network lookup that can be slow (minutes on
28
+ * the indexer, which warms unseen tokens), so the window between truncation and a complete write is a
30
29
  * window in which an interruption, a full disk, or an I/O error leaves the
31
30
  * curator with an empty or half-written playlist and no copy of what they had.
32
31
  * A rename within the same directory is atomic, so the original file survives
@@ -273,7 +272,7 @@ async function syncDirectory(directory, filesystem) {
273
272
  *
274
273
  * Enrichment refuses to work on a document DP-1 rejects, and refuses to write
275
274
  * one. Writing an invalid playlist would replace the operator's file with
276
- * something sign, publish, and play all reject later, having reported success.
275
+ * something sign, publish, and display all reject later, having reported success.
277
276
  */
278
277
  function reportInvalid(stage, result) {
279
278
  console.error(chalk_1.default.red(`\n${stage}`));
@@ -295,10 +294,10 @@ const SKIP_COPY = {
295
294
  'external-ref': 'carries an external ref, which outranks an inline manifest',
296
295
  'no-provenance': 'no provenance.contract chain/address/tokenId to look up',
297
296
  'ambiguous-chain': 'chain "evm" names a family; pass --assume-ethereum to assert the network',
298
- // The indexer drops unresolved tokens from its response rather than
299
- // reporting why, so there is no per-item reason to relay here.
300
- 'not-indexed': 'the indexer returned nothing for it',
301
- 'no-metadata': 'the indexer resolved it but has no artist, description, or still image',
297
+ // The lookup adapter keeps only resolved tokens, so there is no per-item
298
+ // reason to relay here.
299
+ 'not-indexed': 'the token source returned nothing for it',
300
+ 'no-metadata': 'the token source resolved it but has no artist, description, or still image',
302
301
  };
303
302
  exports.enrichCommand = new commander_1.Command('enrich')
304
303
  .description('Add missing artist, title, and thumbnail metadata to a playlist')
@@ -322,7 +321,7 @@ exports.enrichCommand = new commander_1.Command('enrich')
322
321
  console.error(chalk_1.default.red('That playlist has no items.'));
323
322
  process.exit(1);
324
323
  }
325
- // Validate before spending minutes on indexer lookups for a document
324
+ // Validate before spending time on token lookups for a document
326
325
  // that was never going to be writable.
327
326
  const before = await (0, playlist_verifier_1.validatePlaylist)(playlist);
328
327
  if (!before.valid) {
@@ -335,20 +334,16 @@ exports.enrichCommand = new commander_1.Command('enrich')
335
334
  printedProgress = true;
336
335
  process.stdout.write(chalk_1.default.dim(`\r ${done}/${count} looked up...`));
337
336
  };
338
- // resolveTokenBatch is (tokens, duration, onProgress) — the second
339
- // positional is DP-1 display seconds, not the callback. Enrichment never
340
- // sets duration (the curator's timing is not ours to touch), so it is
341
- // passed undefined and the callback goes third. find.ts carries the same
342
- // warning; getting this wrong silently returns unusable results.
343
- //
344
- // The raw results are used rather than getNFTTokenInfoBatch's items so
345
- // the indexer's still rides beside each item: the item itself drops the
346
- // still whenever it equals the indexer's chosen source, and enrichment
337
+ // Enrichment never sets duration (the curator's timing is not ours to
338
+ // touch). The raw results are used rather than bare items so the token
339
+ // source's still rides beside each item: the item itself drops the still
340
+ // whenever it equals the source the token source chose, and enrichment
347
341
  // needs it against the curator's source instead.
342
+ const source = (0, token_source_1.getTokenSource)();
348
343
  const lookup = async (tokens, onProgressCallback) => {
349
- const results = await resolveTokenBatch(tokens, undefined, onProgressCallback);
344
+ const results = await source.resolveTokens(tokens, { onProgress: onProgressCallback });
350
345
  return results
351
- .filter((result) => result.success && result.item)
346
+ .filter(token_source_1.isResolved)
352
347
  .map((result) => ({ ...result.item, still: result.still }));
353
348
  };
354
349
  const result = await (0, enrich_playlist_1.enrichPlaylistManifests)(playlist, lookup, {
@@ -375,7 +370,7 @@ exports.enrichCommand = new commander_1.Command('enrich')
375
370
  if (shouldWrite) {
376
371
  // Re-validate the enriched candidate. An inline manifest is schema-
377
372
  // checked the same way a fetched one is (playlists extension §3.6), so
378
- // a malformed manifest from the indexer must not reach the file.
373
+ // a malformed manifest from the token source must not reach the file.
379
374
  const after = await (0, playlist_verifier_1.validatePlaylist)(result.playlist);
380
375
  if (!after.valid) {
381
376
  reportInvalid('Enrichment produced an invalid playlist:', after);
@@ -409,7 +404,7 @@ exports.enrichCommand = new commander_1.Command('enrich')
409
404
  // fails at the device, which is the worst place to discover it.
410
405
  if (result.signatureInvalidated) {
411
406
  console.log(chalk_1.default.yellow('\n Signature removed — the document changed.'));
412
- 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}`));
413
408
  }
414
409
  console.log();
415
410
  }