@feralfile/cli 2.10.0 → 3.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 +16 -11
- package/config.json.example +5 -0
- package/dist/package.json +2 -2
- package/dist/src/commands/build.js +40 -0
- package/dist/src/commands/config.js +1 -0
- package/dist/src/commands/device.js +1 -1
- package/dist/src/commands/enrich.js +16 -21
- package/dist/src/commands/find.js +94 -81
- package/dist/src/commands/helpers/prompt.js +85 -10
- package/dist/src/commands/play.js +138 -73
- package/dist/src/commands/setup.js +81 -6
- package/dist/src/commands/status.js +4 -1
- package/dist/src/config.js +94 -0
- package/dist/src/main.js +14 -5
- package/dist/src/utilities/device-lookup.js +36 -11
- package/dist/src/utilities/feral-file-artwork.js +2 -2
- package/dist/src/utilities/index.js +270 -259
- package/dist/src/utilities/media-url.js +117 -0
- package/dist/src/utilities/nft-indexer.js +12 -376
- package/dist/src/utilities/playlist-builder.js +10 -1
- package/dist/src/utilities/playlist-source.js +222 -33
- package/dist/src/utilities/raster-client.js +100 -13
- package/dist/src/utilities/token-item.js +428 -0
- package/dist/src/utilities/token-source-contract.js +55 -0
- package/dist/src/utilities/token-source-indexer.js +99 -0
- package/dist/src/utilities/token-source-raster.js +378 -0
- package/dist/src/utilities/token-source.js +48 -0
- package/docs/CONFIGURATION.md +63 -2
- package/docs/EXAMPLES.md +5 -2
- package/docs/PROJECT_SPEC.md +23 -17
- package/docs/README.md +41 -16
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -26,7 +26,8 @@ npx @feralfile/cli find https://www.artblocks.io/collection/ringers-by-dmitri-ch
|
|
|
26
26
|
```bash
|
|
27
27
|
ff-cli setup
|
|
28
28
|
ff-cli find https://objkt.com/tokens/hicetnunc/111068 --play
|
|
29
|
-
ff-cli play "https://example.com/video.mp4"
|
|
29
|
+
ff-cli play "https://example.com/video.mp4"
|
|
30
|
+
ff-cli play "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
|
-
#
|
|
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,6 +52,10 @@ 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 plays by default; add `--no-play` 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
|
|
|
56
61
|
## Find an artwork
|
|
@@ -75,18 +80,18 @@ Sources: Art Blocks, Objkt, fxhash (canonical `/gentk/...`, live `/iteration/{sl
|
|
|
75
80
|
|
|
76
81
|
**Known limitations**
|
|
77
82
|
|
|
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)).
|
|
83
|
+
- **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
84
|
- **Mainstream PFPs** (Azuki, BAYC, Pudgy Penguins) build a single-item playlist instead of a series — Raster's curation is effectively the series allowlist.
|
|
80
85
|
- **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).
|
|
81
87
|
|
|
82
88
|
## Use with Claude Code
|
|
83
89
|
|
|
84
90
|
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
91
|
|
|
86
92
|
```bash
|
|
87
|
-
git clone --depth=1 https://github.com/feral-file/ff-cli
|
|
88
|
-
&& mkdir -p ~/.claude/skills
|
|
89
|
-
&& cp -r /tmp/ff-cli/skills/ff-control ~/.claude/skills/
|
|
93
|
+
d="$(mktemp -d)" && git clone --depth=1 https://github.com/feral-file/ff-cli "$d" \
|
|
94
|
+
&& mkdir -p ~/.claude/skills && cp -r "$d/skills/ff-control" ~/.claude/skills/
|
|
90
95
|
```
|
|
91
96
|
|
|
92
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).
|
|
@@ -96,9 +101,8 @@ Claude Code will surface it when you ask to build a playlist, play an artwork or
|
|
|
96
101
|
ff-cli ships the same `ff-control` skill in Codex's native skill format. Install it once:
|
|
97
102
|
|
|
98
103
|
```bash
|
|
99
|
-
git clone --depth=1 https://github.com/feral-file/ff-cli
|
|
100
|
-
&& mkdir -p ~/.codex/skills
|
|
101
|
-
&& cp -r /tmp/ff-cli/skills/ff-control ~/.codex/skills/
|
|
104
|
+
d="$(mktemp -d)" && git clone --depth=1 https://github.com/feral-file/ff-cli "$d" \
|
|
105
|
+
&& mkdir -p ~/.codex/skills && cp -r "$d/skills/ff-control" ~/.codex/skills/
|
|
102
106
|
```
|
|
103
107
|
|
|
104
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).
|
|
@@ -109,8 +113,9 @@ Codex will surface it when you ask to build a playlist, play an artwork or URL o
|
|
|
109
113
|
npm ci
|
|
110
114
|
npm run dev -- setup
|
|
111
115
|
npm run dev -- find https://objkt.com/tokens/hicetnunc/111068 --play
|
|
112
|
-
npm run dev -- play "https://example.com/video.mp4"
|
|
116
|
+
npm run dev -- play "https://example.com/video.mp4"
|
|
113
117
|
```
|
|
118
|
+
|
|
114
119
|
## Documentation
|
|
115
120
|
|
|
116
121
|
- Getting started and usage: `./docs/README.md`
|
package/config.json.example
CHANGED
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feralfile/cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "CLI for building, signing, and playing DP-1 playlists of digital art on Feral File devices",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"chalk": "^4.1.2",
|
|
66
66
|
"commander": "^11.1.0",
|
|
67
67
|
"dotenv": "^16.3.1",
|
|
68
|
-
"dp1-js": "2.
|
|
68
|
+
"dp1-js": "2.4.1",
|
|
69
69
|
"ff1": "^1.0.0",
|
|
70
70
|
"fuzzysort": "^3.1.0",
|
|
71
71
|
"viem": "^2.56.3"
|
|
@@ -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 (play 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
|
|
@@ -120,7 +120,7 @@ deviceCommand
|
|
|
120
120
|
let prompt = null;
|
|
121
121
|
const ask = async (question) => {
|
|
122
122
|
if (!prompt) {
|
|
123
|
-
prompt = (0, prompt_1.createPrompt)();
|
|
123
|
+
prompt = (0, prompt_1.createPrompt)('Pass --host http://<device-ip>:1111 and --name <name> to add or update a device without questions.');
|
|
124
124
|
}
|
|
125
125
|
return prompt.ask(question);
|
|
126
126
|
};
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
@@ -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
|
|
299
|
-
//
|
|
300
|
-
'not-indexed': 'the
|
|
301
|
-
'no-metadata': 'the
|
|
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
|
|
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
|
-
//
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
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
|
|
344
|
+
const results = await source.resolveTokens(tokens, { onProgress: onProgressCallback });
|
|
350
345
|
return results
|
|
351
|
-
.filter(
|
|
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
|
|
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);
|
|
@@ -4,7 +4,6 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.findCommand = void 0;
|
|
7
|
-
exports.buildRasterMediaItems = buildRasterMediaItems;
|
|
8
7
|
exports.parseLimitOption = parseLimitOption;
|
|
9
8
|
exports.decideActions = decideActions;
|
|
10
9
|
const commander_1 = require("commander");
|
|
@@ -22,9 +21,10 @@ const neort_marketplace_1 = require("../utilities/neort-marketplace");
|
|
|
22
21
|
const verse_marketplace_1 = require("../utilities/verse-marketplace");
|
|
23
22
|
const raster_client_1 = require("../utilities/raster-client");
|
|
24
23
|
const playlist_cast_1 = require("../utilities/playlist-cast");
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
24
|
+
const token_source_1 = require("../utilities/token-source");
|
|
25
|
+
const token_source_raster_1 = require("../utilities/token-source-raster");
|
|
26
|
+
const config_1 = require("../config");
|
|
27
|
+
// playlist-builder is still CommonJS; require keeps interop simple.
|
|
28
28
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
29
29
|
const { buildDP1Playlist, buildUrlItem } = require('../utilities/playlist-builder');
|
|
30
30
|
/**
|
|
@@ -35,16 +35,34 @@ const { buildDP1Playlist, buildUrlItem } = require('../utilities/playlist-builde
|
|
|
35
35
|
*/
|
|
36
36
|
const DP1_MAX_ITEMS = 1024;
|
|
37
37
|
const RASTER_PAGE_SIZE = 100;
|
|
38
|
+
/**
|
|
39
|
+
* FIND_NON_INTERACTIVE_HINT is what `find` tells a caller without a terminal
|
|
40
|
+
* when one of its questions comes up. `-y` answers every question with its
|
|
41
|
+
* default, and that default is Play, so the hint names `--no-play` in the same
|
|
42
|
+
* breath for a caller that only wants the file.
|
|
43
|
+
*/
|
|
44
|
+
const FIND_NON_INTERACTIVE_HINT = 'Re-run with -y to accept the defaults (it builds the playlist and plays it on your FF1); add --no-play to only save it.';
|
|
45
|
+
/**
|
|
46
|
+
* playAPageHint points a caller who handed `find` a page it cannot resolve to
|
|
47
|
+
* the command that shows a page as it is.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} input - The URL the caller gave `find`
|
|
50
|
+
* @returns {string} One line naming `ff-cli play --web`
|
|
51
|
+
*/
|
|
52
|
+
function playAPageHint(input) {
|
|
53
|
+
return `To show this page itself on your FF1, run: ff-cli play --web "${input}"`;
|
|
54
|
+
}
|
|
38
55
|
exports.findCommand = new commander_1.Command('find')
|
|
39
56
|
.description('Find an artwork on the web and build a DP-1 playlist')
|
|
40
|
-
.argument('<input>', 'URL (Objkt / fxhash / Art Blocks / OpenSea / SuperRare / Feral File / Neort / Verse / Raster), `ethereum:{contract}:{tokenId}`, `tezos:{contract}:{tokenId}`, or a wallet address')
|
|
57
|
+
.argument('<input>', 'URL (Objkt / fxhash / Art Blocks / OpenSea / SuperRare / Feral File / Neort / Verse / Raster), `ethereum:{contract}:{tokenId}`, `tezos:{contract}:{tokenId}`, or a wallet address (picks one work by the artist at that address; for what a wallet owns, see `ff-cli build --help`)')
|
|
41
58
|
.option('-o, --output <path>', 'Save the playlist to this file (default: ./<slug>.json)')
|
|
42
59
|
.option('-l, --limit <n>', 'Max tokens to include from the series (default: all)')
|
|
43
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')
|
|
44
62
|
.option('-d, --device <name>', 'Device to play on (used with --play; default: first configured)')
|
|
45
63
|
.option('--publish', 'Publish the playlist to a configured feed server')
|
|
46
64
|
.option('-s, --server <index>', 'Feed server index (used with --publish)')
|
|
47
|
-
.option('-y, --yes', 'Skip interactive prompts
|
|
65
|
+
.option('-y, --yes', 'Skip interactive prompts. Without --output or --publish, -y plays the playlist on a device; add --no-play to skip casting')
|
|
48
66
|
.option('--skip-verify', 'Skip signature verification before --play (mirrors `ff-cli play --skip-verify`)')
|
|
49
67
|
.action(async (input, options) => {
|
|
50
68
|
try {
|
|
@@ -55,6 +73,11 @@ exports.findCommand = new commander_1.Command('find')
|
|
|
55
73
|
console.error(chalk_1.default.dim('Supported URLs: Objkt, fxhash, Art Blocks, OpenSea, SuperRare, Feral File, Neort, Verse, Raster. ' +
|
|
56
74
|
'Or: `ethereum:{contract}:{tokenId}`, `tezos:{contract}:{tokenId}`, ' +
|
|
57
75
|
'or a wallet address (`0x...` / `tz1.../tz2.../tz3...`).'));
|
|
76
|
+
// A plain web page or media URL is not an artwork lookup at all; `play`
|
|
77
|
+
// casts those directly, so name it rather than leave the caller stuck.
|
|
78
|
+
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}"`));
|
|
80
|
+
}
|
|
58
81
|
process.exit(1);
|
|
59
82
|
}
|
|
60
83
|
if (parsed.kind === 'unsupported') {
|
|
@@ -64,9 +87,12 @@ exports.findCommand = new commander_1.Command('find')
|
|
|
64
87
|
return;
|
|
65
88
|
}
|
|
66
89
|
console.error(chalk_1.default.red(parsed.reason));
|
|
90
|
+
// `play` sends unrecognized pages on these hosts here, so the way out
|
|
91
|
+
// of that loop has to be printed on this side too.
|
|
92
|
+
console.error(chalk_1.default.dim(playAPageHint(input)));
|
|
67
93
|
process.exit(1);
|
|
68
94
|
}
|
|
69
|
-
// Neort is off-chain; its items skip
|
|
95
|
+
// Neort is off-chain; its items skip the token source entirely and
|
|
70
96
|
// build a DP-1 entry directly from Neort's API response.
|
|
71
97
|
if (parsed.kind === 'neort-art') {
|
|
72
98
|
await runNeortFind(parsed.id, options);
|
|
@@ -112,14 +138,13 @@ async function runResolvedTarget(target, options) {
|
|
|
112
138
|
else if (target.kind === 'token-list') {
|
|
113
139
|
const allTokens = target.coords.map((coords) => ({
|
|
114
140
|
...coords,
|
|
115
|
-
|
|
116
|
-
mediaType: null,
|
|
141
|
+
rasterToken: null,
|
|
117
142
|
}));
|
|
118
143
|
tokens = allTokens.slice(0, limit);
|
|
119
144
|
seriesHasMore = allTokens.length > limit || !!target.hasMore;
|
|
120
145
|
}
|
|
121
146
|
else {
|
|
122
|
-
tokens = [{ ...target.coords,
|
|
147
|
+
tokens = [{ ...target.coords, rasterToken: null }];
|
|
123
148
|
}
|
|
124
149
|
if (tokens.length === 0) {
|
|
125
150
|
console.error(chalk_1.default.red('Series has no tokens on supported chains (Ethereum + Tezos mainnet).'));
|
|
@@ -135,48 +160,21 @@ async function runResolvedTarget(target, options) {
|
|
|
135
160
|
console.log(chalk_1.default.dim('Cancelled.'));
|
|
136
161
|
return;
|
|
137
162
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
if (tokens.length > 10) {
|
|
163
|
+
const source = (0, token_source_1.getTokenSource)();
|
|
164
|
+
const label = source.name === 'raster' ? 'Raster' : 'FF indexer';
|
|
165
|
+
console.log(chalk_1.default.dim(`Resolving ${tokens.length} token${tokens.length === 1 ? '' : 's'} via ${label}...`));
|
|
166
|
+
// The indexer warms previously-unseen tokens by polling, which
|
|
167
|
+
// takes minutes on a large series; Raster answers from its own index.
|
|
168
|
+
if (source.name === 'indexer' && tokens.length > 10) {
|
|
144
169
|
console.log(chalk_1.default.dim(' First-time tokens can take the indexer a while to warm; use `--limit 5` for a faster first pass.'));
|
|
145
170
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
contractAddress: t.contract,
|
|
153
|
-
tokenId: t.tokenId,
|
|
154
|
-
})), undefined,
|
|
155
|
-
// Unconditional: suppressing the done===total call meant a one-batch
|
|
156
|
-
// index (including the recommended --limit 5 warm-up) printed nothing
|
|
157
|
-
// and multi-batch runs never showed completion. The final N/N line IS
|
|
158
|
-
// the "indexing finished" signal.
|
|
159
|
-
(done, total) => {
|
|
160
|
-
console.log(chalk_1.default.dim(` ${done}/${total} tokens indexed...`));
|
|
161
|
-
});
|
|
162
|
-
// FF-indexer bypass for Raster-minted tokens. The indexer doesn't carry
|
|
163
|
-
// Raster's tokens, so it returns nothing — but Raster handed us each
|
|
164
|
-
// token's playable `media.contentUrl`. Build DP-1 items straight from
|
|
165
|
-
// that (off-chain provenance, same shape as the Neort path), so the
|
|
166
|
-
// series still builds into a signable, verifiable playlist instead of
|
|
167
|
-
// failing the way it did before. The indexer is tried first so tokens
|
|
168
|
-
// it *can* resolve keep their richer on-chain provenance.
|
|
169
|
-
if (!Array.isArray(items) || items.length === 0) {
|
|
170
|
-
const rasterItems = buildRasterMediaItems(tokens, target);
|
|
171
|
-
if (rasterItems.length > 0) {
|
|
172
|
-
console.log(chalk_1.default.dim(` FF indexer returned nothing; building ${rasterItems.length} item${rasterItems.length === 1 ? '' : 's'} directly from Raster media.`));
|
|
173
|
-
items = rasterItems;
|
|
171
|
+
const results = await resolveBuildTokens(source, tokens);
|
|
172
|
+
const items = (0, token_source_1.itemsOf)(results);
|
|
173
|
+
if (items.length === 0) {
|
|
174
|
+
console.error(chalk_1.default.red(`No tokens could be resolved via ${label}; cannot build playlist.`));
|
|
175
|
+
for (const message of (0, token_source_1.failureMessages)(results)) {
|
|
176
|
+
console.error(chalk_1.default.dim(` ${message}`));
|
|
174
177
|
}
|
|
175
|
-
}
|
|
176
|
-
if (!Array.isArray(items) || items.length === 0) {
|
|
177
|
-
console.error(chalk_1.default.red('No tokens could be indexed; cannot build playlist.'));
|
|
178
|
-
console.error(chalk_1.default.dim(' The FF indexer may be under load when warming a series of previously-unseen tokens.'));
|
|
179
|
-
console.error(chalk_1.default.dim(' Try `--limit 5` first to warm the cache, then re-run without --limit.'));
|
|
180
178
|
process.exit(1);
|
|
181
179
|
}
|
|
182
180
|
const title = playlistTitleFor(target, items);
|
|
@@ -187,7 +185,10 @@ async function runResolvedTarget(target, options) {
|
|
|
187
185
|
console.log(chalk_1.default.dim(` ${items.length} item${items.length === 1 ? '' : 's'}`));
|
|
188
186
|
const dropped = tokens.length - items.length;
|
|
189
187
|
if (dropped > 0) {
|
|
190
|
-
console.log(chalk_1.default.dim(` ${dropped} token${dropped === 1 ? '' : 's'}
|
|
188
|
+
console.log(chalk_1.default.dim(` ${dropped} token${dropped === 1 ? '' : 's'} could not be resolved:`));
|
|
189
|
+
for (const message of (0, token_source_1.failureMessages)(results)) {
|
|
190
|
+
console.log(chalk_1.default.dim(` ${message}`));
|
|
191
|
+
}
|
|
191
192
|
}
|
|
192
193
|
console.log();
|
|
193
194
|
const actions = await decideActions(options);
|
|
@@ -278,6 +279,11 @@ async function resolveTarget(input, parsed, skipPrompt, limitOption) {
|
|
|
278
279
|
if (catalog.length === 0) {
|
|
279
280
|
throw new Error('Artist has no artworks indexed on Raster.');
|
|
280
281
|
}
|
|
282
|
+
// A wallet address reads naturally as "what this wallet owns", and this
|
|
283
|
+
// path builds something else: one work by the artist at the address. Say
|
|
284
|
+
// 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. ' +
|
|
286
|
+
'For what the wallet owns, use build with a query_address requirement (ff-cli build --help).'));
|
|
281
287
|
const picked = await pickFromCatalog(catalog, skipPrompt);
|
|
282
288
|
return {
|
|
283
289
|
kind: 'series',
|
|
@@ -344,7 +350,7 @@ async function resolveCoords(coords) {
|
|
|
344
350
|
}
|
|
345
351
|
/**
|
|
346
352
|
* Build the playlist title. For series, "Artist — Series Title". For a
|
|
347
|
-
* one-item fallback, use the
|
|
353
|
+
* one-item fallback, use the token source's name (already in the item)
|
|
348
354
|
* with a fall-through to coords if name is missing.
|
|
349
355
|
*/
|
|
350
356
|
function playlistTitleFor(target, items) {
|
|
@@ -358,30 +364,30 @@ function playlistTitleFor(target, items) {
|
|
|
358
364
|
return items[0]?.title ?? `Token ${target.coords.tokenId}`;
|
|
359
365
|
}
|
|
360
366
|
/**
|
|
361
|
-
*
|
|
362
|
-
* indexer. Used as a fallback when the indexer resolves nothing — tokens
|
|
363
|
-
* without a Raster `mediaUrl` are dropped (nothing playable to point at).
|
|
367
|
+
* resolveBuildTokens turns the tokens to build into results, one per token.
|
|
364
368
|
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
+
* When every token already carries its Raster row (a Raster series page) and
|
|
370
|
+
* Raster is the token source, the rows are mapped directly: the page already
|
|
371
|
+
* paid for every field, so asking again would double the requests. Any other
|
|
372
|
+
* mix goes through the token source by coordinate.
|
|
369
373
|
*/
|
|
370
|
-
function
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
374
|
+
async function resolveBuildTokens(source, tokens) {
|
|
375
|
+
const rows = tokens.map((t) => t.rasterToken);
|
|
376
|
+
if (source.name === 'raster' && rows.every((row) => row !== null)) {
|
|
377
|
+
const entries = tokens.map((t, i) => ({ token: rows[i], requested: coordinateOf(t) }));
|
|
378
|
+
return (0, token_source_raster_1.itemsFromRasterTokens)(entries, (0, config_1.getTokenSourceConfig)().ipfsGateways);
|
|
379
|
+
}
|
|
380
|
+
// Unconditional progress: the final N/N line IS the "resolution finished"
|
|
381
|
+
// signal, including for a single-batch run.
|
|
382
|
+
return source.resolveTokens(tokens.map(coordinateOf), {
|
|
383
|
+
onProgress: (done, total) => {
|
|
384
|
+
console.log(chalk_1.default.dim(` ${done}/${total} tokens resolved...`));
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
/** coordinateOf converts a find token to the token source's coordinate shape. */
|
|
389
|
+
function coordinateOf(token) {
|
|
390
|
+
return { chain: token.chain, contractAddress: token.contract, tokenId: token.tokenId };
|
|
385
391
|
}
|
|
386
392
|
function parseLimitOption(limitStr) {
|
|
387
393
|
if (limitStr === undefined) {
|
|
@@ -424,8 +430,7 @@ async function fetchTokens(summary, limit) {
|
|
|
424
430
|
chain: t.chain,
|
|
425
431
|
contract: t.contractAddress,
|
|
426
432
|
tokenId: t.tokenId,
|
|
427
|
-
|
|
428
|
-
mediaType: t.mediaType,
|
|
433
|
+
rasterToken: t.token,
|
|
429
434
|
});
|
|
430
435
|
}
|
|
431
436
|
if (hasMore || page.nextCursor === null) {
|
|
@@ -438,7 +443,7 @@ async function fetchTokens(summary, limit) {
|
|
|
438
443
|
cursor = page.nextCursor;
|
|
439
444
|
}
|
|
440
445
|
if (skipped > 0) {
|
|
441
|
-
console.log(chalk_1.default.yellow(` Skipped ${skipped} token${skipped === 1 ? '' : 's'} on unsupported chains (
|
|
446
|
+
console.log(chalk_1.default.yellow(` Skipped ${skipped} token${skipped === 1 ? '' : 's'} on unsupported chains (ff-cli builds Ethereum and Tezos mainnet only).`));
|
|
442
447
|
}
|
|
443
448
|
return { tokens: collected, hasMore };
|
|
444
449
|
}
|
|
@@ -451,7 +456,7 @@ async function pickFromCatalog(catalog, skipPrompt) {
|
|
|
451
456
|
console.log(chalk_1.default.dim(` ${i}: ${row.title}`));
|
|
452
457
|
});
|
|
453
458
|
console.log();
|
|
454
|
-
const prompt = (0, prompt_1.createPrompt)();
|
|
459
|
+
const prompt = (0, prompt_1.createPrompt)(FIND_NON_INTERACTIVE_HINT);
|
|
455
460
|
const answer = await prompt.ask(`Pick one (0-${catalog.length - 1}, default 0): `);
|
|
456
461
|
prompt.close();
|
|
457
462
|
console.log();
|
|
@@ -466,7 +471,7 @@ async function pickFromCatalog(catalog, skipPrompt) {
|
|
|
466
471
|
async function confirmMakePlaylist(count, hasMore) {
|
|
467
472
|
const noun = count === 1 ? 'token' : 'tokens';
|
|
468
473
|
const label = hasMore ? `the first ${count} ${noun}` : `${count} ${noun}`;
|
|
469
|
-
const prompt = (0, prompt_1.createPrompt)();
|
|
474
|
+
const prompt = (0, prompt_1.createPrompt)(FIND_NON_INTERACTIVE_HINT);
|
|
470
475
|
const yes = await (0, prompt_1.promptYesNo)(prompt.ask, `Build playlist with ${label}?`, true);
|
|
471
476
|
prompt.close();
|
|
472
477
|
console.log();
|
|
@@ -478,9 +483,16 @@ async function confirmMakePlaylist(count, hasMore) {
|
|
|
478
483
|
* Precedence: explicit flags (--play / --publish) win and may combine. If no
|
|
479
484
|
* flag is set, fall back to --yes default (Play) or an interactive 3-way
|
|
480
485
|
* prompt. Save is unconditional and already done before this is called.
|
|
486
|
+
*
|
|
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.
|
|
481
492
|
*/
|
|
482
493
|
async function decideActions(options) {
|
|
483
494
|
const flagActions = [];
|
|
495
|
+
const noPlay = options.play === false;
|
|
484
496
|
if (options.play) {
|
|
485
497
|
flagActions.push('play');
|
|
486
498
|
}
|
|
@@ -491,7 +503,8 @@ async function decideActions(options) {
|
|
|
491
503
|
return flagActions;
|
|
492
504
|
}
|
|
493
505
|
// --output alone (with or without --yes) means "save mode" — the save was the action.
|
|
494
|
-
|
|
506
|
+
// --no-play likewise means the save was the action; never prompt or default to Play.
|
|
507
|
+
if (options.output || noPlay) {
|
|
495
508
|
return [];
|
|
496
509
|
}
|
|
497
510
|
// --yes without --output defaults to Play (max-satisfaction default for scripted use).
|
|
@@ -501,7 +514,7 @@ async function decideActions(options) {
|
|
|
501
514
|
return [await promptNextAction()];
|
|
502
515
|
}
|
|
503
516
|
async function promptNextAction() {
|
|
504
|
-
const prompt = (0, prompt_1.createPrompt)();
|
|
517
|
+
const prompt = (0, prompt_1.createPrompt)('The playlist is saved. Pass --play to cast it, or --publish to publish it.');
|
|
505
518
|
const answer = (await prompt.ask('Next? [P]lay on FF1 / just [S]ave / pub[L]ish to feed (default: Play): '))
|
|
506
519
|
.trim()
|
|
507
520
|
.toLowerCase();
|
|
@@ -601,7 +614,7 @@ async function doPublish(savedPath, serverArg, nonInteractive) {
|
|
|
601
614
|
}
|
|
602
615
|
/**
|
|
603
616
|
* Neort flow. Off-chain platform with no on-chain coords, so we bypass
|
|
604
|
-
*
|
|
617
|
+
* the token source and construct a single-item DP-1 playlist directly
|
|
605
618
|
* from Neort's API response using the off-chain `provenance.offChainURI`
|
|
606
619
|
* shape produced by `buildUrlItem`.
|
|
607
620
|
*/
|