@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.
- package/README.md +20 -17
- package/config.json.example +1 -1
- package/dist/index.js +10 -2
- package/dist/package.json +5 -2
- package/dist/src/commands/build.js +1 -1
- package/dist/src/commands/device.js +84 -39
- package/dist/src/commands/enrich.js +2 -2
- package/dist/src/commands/find.js +71 -37
- package/dist/src/commands/helpers/device-control.js +937 -0
- package/dist/src/commands/helpers/hidden-alias-help.js +37 -0
- package/dist/src/commands/helpers/playlist-display.js +2 -15
- package/dist/src/commands/play.js +39 -26
- package/dist/src/commands/report.js +251 -0
- package/dist/src/commands/setup.js +1 -1
- package/dist/src/config.js +162 -6
- package/dist/src/utilities/control/codec.js +578 -0
- package/dist/src/utilities/control/control-errors.js +157 -0
- package/dist/src/utilities/control/controller-store.js +964 -0
- package/dist/src/utilities/control/jwk.js +141 -0
- package/dist/src/utilities/control/pairing-channel.js +52 -0
- package/dist/src/utilities/control/pairing.js +416 -0
- package/dist/src/utilities/control/registration.js +391 -0
- package/dist/src/utilities/control/relay-pairing-channel.js +191 -0
- package/dist/src/utilities/control/secure-transport.js +850 -0
- package/dist/src/utilities/control/strict-json.js +376 -0
- package/dist/src/utilities/device-network.js +129 -0
- package/dist/src/utilities/device-upsert.js +1 -1
- package/dist/src/utilities/ff1-compatibility.js +87 -11
- package/dist/src/utilities/ff1-device.js +161 -78
- package/dist/src/utilities/ff1-relayer.js +34 -14
- package/dist/src/utilities/handoff-client.js +23 -9
- package/dist/src/utilities/neort-marketplace.js +3 -3
- package/dist/src/utilities/playlist-builder.js +3 -3
- package/dist/src/utilities/playlist-cast.js +6 -6
- package/dist/src/utilities/playlist-source.js +7 -7
- package/dist/src/utilities/raster-client.js +1 -1
- package/dist/src/utilities/relay-client.js +774 -0
- package/dist/src/utilities/ssh-access.js +192 -58
- package/dist/src/utilities/support-report.js +511 -0
- package/dist/src/utilities/temporary-channel.js +139 -0
- package/docs/CONFIGURATION.md +270 -28
- package/docs/EXAMPLES.md +31 -31
- package/docs/PROJECT_SPEC.md +32 -19
- package/docs/README.md +113 -36
- 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
|
|
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 --
|
|
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 --
|
|
29
|
-
ff-cli
|
|
30
|
-
ff-cli
|
|
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
|
|
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
|
|
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,
|
|
67
|
-
ff-cli find https://www.artblocks.io/collection/ringers-by-dmitri-cherniak --
|
|
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 --
|
|
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 --
|
|
75
|
+
ff-cli find https://feralfile.com/exhibitions/artwork/f0240e04d64717e319584957f6a83954b029254ad1260b6320472ea8c0c5b1cf --display
|
|
74
76
|
|
|
75
|
-
# Save without
|
|
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
|
|
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,
|
|
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,
|
|
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 --
|
|
116
|
-
npm run dev --
|
|
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
|
|
package/config.json.example
CHANGED
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
|
-
|
|
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.
|
|
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": "
|
|
4
|
-
"description": "CLI for building, signing, and
|
|
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 (
|
|
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
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
55
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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('
|
|
61
|
-
.option('--no-
|
|
62
|
-
|
|
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
|
|
66
|
-
.option('--skip-verify', 'Skip signature verification before --
|
|
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; `
|
|
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
|
|
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
|
-
// `
|
|
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
|
|
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
|
|
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
|
|
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 (--
|
|
484
|
-
* flag is set, fall back to --yes default (
|
|
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-
|
|
488
|
-
* casting from every path, including the `--yes` default and the
|
|
489
|
-
* prompt. It is additive: `--yes` alone keeps its deliberate
|
|
490
|
-
* default for scripted use" of
|
|
491
|
-
* `--no-
|
|
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
|
|
496
|
-
|
|
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-
|
|
507
|
-
if (options.output ||
|
|
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
|
|
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 --
|
|
518
|
-
const answer = (await prompt.ask('Next? [
|
|
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
|
-
|
|
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
|
|
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
|
|
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('
|
|
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('
|
|
568
|
-
console.log(chalk_1.default.green('✓
|
|
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
|
}
|