@feralfile/cli 2.10.1 → 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 +1 -1
- 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 +83 -79
- 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 +2 -2
- package/docs/PROJECT_SPEC.md +23 -17
- package/docs/README.md +40 -15
- package/package.json +1 -1
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
|
@@ -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,9 +35,26 @@ 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')
|
|
@@ -56,6 +73,11 @@ exports.findCommand = new commander_1.Command('find')
|
|
|
56
73
|
console.error(chalk_1.default.dim('Supported URLs: Objkt, fxhash, Art Blocks, OpenSea, SuperRare, Feral File, Neort, Verse, Raster. ' +
|
|
57
74
|
'Or: `ethereum:{contract}:{tokenId}`, `tezos:{contract}:{tokenId}`, ' +
|
|
58
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
|
+
}
|
|
59
81
|
process.exit(1);
|
|
60
82
|
}
|
|
61
83
|
if (parsed.kind === 'unsupported') {
|
|
@@ -65,9 +87,12 @@ exports.findCommand = new commander_1.Command('find')
|
|
|
65
87
|
return;
|
|
66
88
|
}
|
|
67
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)));
|
|
68
93
|
process.exit(1);
|
|
69
94
|
}
|
|
70
|
-
// Neort is off-chain; its items skip
|
|
95
|
+
// Neort is off-chain; its items skip the token source entirely and
|
|
71
96
|
// build a DP-1 entry directly from Neort's API response.
|
|
72
97
|
if (parsed.kind === 'neort-art') {
|
|
73
98
|
await runNeortFind(parsed.id, options);
|
|
@@ -113,14 +138,13 @@ async function runResolvedTarget(target, options) {
|
|
|
113
138
|
else if (target.kind === 'token-list') {
|
|
114
139
|
const allTokens = target.coords.map((coords) => ({
|
|
115
140
|
...coords,
|
|
116
|
-
|
|
117
|
-
mediaType: null,
|
|
141
|
+
rasterToken: null,
|
|
118
142
|
}));
|
|
119
143
|
tokens = allTokens.slice(0, limit);
|
|
120
144
|
seriesHasMore = allTokens.length > limit || !!target.hasMore;
|
|
121
145
|
}
|
|
122
146
|
else {
|
|
123
|
-
tokens = [{ ...target.coords,
|
|
147
|
+
tokens = [{ ...target.coords, rasterToken: null }];
|
|
124
148
|
}
|
|
125
149
|
if (tokens.length === 0) {
|
|
126
150
|
console.error(chalk_1.default.red('Series has no tokens on supported chains (Ethereum + Tezos mainnet).'));
|
|
@@ -136,48 +160,21 @@ async function runResolvedTarget(target, options) {
|
|
|
136
160
|
console.log(chalk_1.default.dim('Cancelled.'));
|
|
137
161
|
return;
|
|
138
162
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
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) {
|
|
145
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.'));
|
|
146
170
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
contractAddress: t.contract,
|
|
154
|
-
tokenId: t.tokenId,
|
|
155
|
-
})), undefined,
|
|
156
|
-
// Unconditional: suppressing the done===total call meant a one-batch
|
|
157
|
-
// index (including the recommended --limit 5 warm-up) printed nothing
|
|
158
|
-
// and multi-batch runs never showed completion. The final N/N line IS
|
|
159
|
-
// the "indexing finished" signal.
|
|
160
|
-
(done, total) => {
|
|
161
|
-
console.log(chalk_1.default.dim(` ${done}/${total} tokens indexed...`));
|
|
162
|
-
});
|
|
163
|
-
// FF-indexer bypass for Raster-minted tokens. The indexer doesn't carry
|
|
164
|
-
// Raster's tokens, so it returns nothing — but Raster handed us each
|
|
165
|
-
// token's playable `media.contentUrl`. Build DP-1 items straight from
|
|
166
|
-
// that (off-chain provenance, same shape as the Neort path), so the
|
|
167
|
-
// series still builds into a signable, verifiable playlist instead of
|
|
168
|
-
// failing the way it did before. The indexer is tried first so tokens
|
|
169
|
-
// it *can* resolve keep their richer on-chain provenance.
|
|
170
|
-
if (!Array.isArray(items) || items.length === 0) {
|
|
171
|
-
const rasterItems = buildRasterMediaItems(tokens, target);
|
|
172
|
-
if (rasterItems.length > 0) {
|
|
173
|
-
console.log(chalk_1.default.dim(` FF indexer returned nothing; building ${rasterItems.length} item${rasterItems.length === 1 ? '' : 's'} directly from Raster media.`));
|
|
174
|
-
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}`));
|
|
175
177
|
}
|
|
176
|
-
}
|
|
177
|
-
if (!Array.isArray(items) || items.length === 0) {
|
|
178
|
-
console.error(chalk_1.default.red('No tokens could be indexed; cannot build playlist.'));
|
|
179
|
-
console.error(chalk_1.default.dim(' The FF indexer may be under load when warming a series of previously-unseen tokens.'));
|
|
180
|
-
console.error(chalk_1.default.dim(' Try `--limit 5` first to warm the cache, then re-run without --limit.'));
|
|
181
178
|
process.exit(1);
|
|
182
179
|
}
|
|
183
180
|
const title = playlistTitleFor(target, items);
|
|
@@ -188,7 +185,10 @@ async function runResolvedTarget(target, options) {
|
|
|
188
185
|
console.log(chalk_1.default.dim(` ${items.length} item${items.length === 1 ? '' : 's'}`));
|
|
189
186
|
const dropped = tokens.length - items.length;
|
|
190
187
|
if (dropped > 0) {
|
|
191
|
-
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
|
+
}
|
|
192
192
|
}
|
|
193
193
|
console.log();
|
|
194
194
|
const actions = await decideActions(options);
|
|
@@ -279,6 +279,11 @@ async function resolveTarget(input, parsed, skipPrompt, limitOption) {
|
|
|
279
279
|
if (catalog.length === 0) {
|
|
280
280
|
throw new Error('Artist has no artworks indexed on Raster.');
|
|
281
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).'));
|
|
282
287
|
const picked = await pickFromCatalog(catalog, skipPrompt);
|
|
283
288
|
return {
|
|
284
289
|
kind: 'series',
|
|
@@ -345,7 +350,7 @@ async function resolveCoords(coords) {
|
|
|
345
350
|
}
|
|
346
351
|
/**
|
|
347
352
|
* Build the playlist title. For series, "Artist — Series Title". For a
|
|
348
|
-
* one-item fallback, use the
|
|
353
|
+
* one-item fallback, use the token source's name (already in the item)
|
|
349
354
|
* with a fall-through to coords if name is missing.
|
|
350
355
|
*/
|
|
351
356
|
function playlistTitleFor(target, items) {
|
|
@@ -359,30 +364,30 @@ function playlistTitleFor(target, items) {
|
|
|
359
364
|
return items[0]?.title ?? `Token ${target.coords.tokenId}`;
|
|
360
365
|
}
|
|
361
366
|
/**
|
|
362
|
-
*
|
|
363
|
-
* indexer. Used as a fallback when the indexer resolves nothing — tokens
|
|
364
|
-
* without a Raster `mediaUrl` are dropped (nothing playable to point at).
|
|
367
|
+
* resolveBuildTokens turns the tokens to build into results, one per token.
|
|
365
368
|
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
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.
|
|
370
373
|
*/
|
|
371
|
-
function
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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 };
|
|
386
391
|
}
|
|
387
392
|
function parseLimitOption(limitStr) {
|
|
388
393
|
if (limitStr === undefined) {
|
|
@@ -425,8 +430,7 @@ async function fetchTokens(summary, limit) {
|
|
|
425
430
|
chain: t.chain,
|
|
426
431
|
contract: t.contractAddress,
|
|
427
432
|
tokenId: t.tokenId,
|
|
428
|
-
|
|
429
|
-
mediaType: t.mediaType,
|
|
433
|
+
rasterToken: t.token,
|
|
430
434
|
});
|
|
431
435
|
}
|
|
432
436
|
if (hasMore || page.nextCursor === null) {
|
|
@@ -439,7 +443,7 @@ async function fetchTokens(summary, limit) {
|
|
|
439
443
|
cursor = page.nextCursor;
|
|
440
444
|
}
|
|
441
445
|
if (skipped > 0) {
|
|
442
|
-
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).`));
|
|
443
447
|
}
|
|
444
448
|
return { tokens: collected, hasMore };
|
|
445
449
|
}
|
|
@@ -452,7 +456,7 @@ async function pickFromCatalog(catalog, skipPrompt) {
|
|
|
452
456
|
console.log(chalk_1.default.dim(` ${i}: ${row.title}`));
|
|
453
457
|
});
|
|
454
458
|
console.log();
|
|
455
|
-
const prompt = (0, prompt_1.createPrompt)();
|
|
459
|
+
const prompt = (0, prompt_1.createPrompt)(FIND_NON_INTERACTIVE_HINT);
|
|
456
460
|
const answer = await prompt.ask(`Pick one (0-${catalog.length - 1}, default 0): `);
|
|
457
461
|
prompt.close();
|
|
458
462
|
console.log();
|
|
@@ -467,7 +471,7 @@ async function pickFromCatalog(catalog, skipPrompt) {
|
|
|
467
471
|
async function confirmMakePlaylist(count, hasMore) {
|
|
468
472
|
const noun = count === 1 ? 'token' : 'tokens';
|
|
469
473
|
const label = hasMore ? `the first ${count} ${noun}` : `${count} ${noun}`;
|
|
470
|
-
const prompt = (0, prompt_1.createPrompt)();
|
|
474
|
+
const prompt = (0, prompt_1.createPrompt)(FIND_NON_INTERACTIVE_HINT);
|
|
471
475
|
const yes = await (0, prompt_1.promptYesNo)(prompt.ask, `Build playlist with ${label}?`, true);
|
|
472
476
|
prompt.close();
|
|
473
477
|
console.log();
|
|
@@ -510,7 +514,7 @@ async function decideActions(options) {
|
|
|
510
514
|
return [await promptNextAction()];
|
|
511
515
|
}
|
|
512
516
|
async function promptNextAction() {
|
|
513
|
-
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.');
|
|
514
518
|
const answer = (await prompt.ask('Next? [P]lay on FF1 / just [S]ave / pub[L]ish to feed (default: Play): '))
|
|
515
519
|
.trim()
|
|
516
520
|
.toLowerCase();
|
|
@@ -610,7 +614,7 @@ async function doPublish(savedPath, serverArg, nonInteractive) {
|
|
|
610
614
|
}
|
|
611
615
|
/**
|
|
612
616
|
* Neort flow. Off-chain platform with no on-chain coords, so we bypass
|
|
613
|
-
*
|
|
617
|
+
* the token source and construct a single-item DP-1 playlist directly
|
|
614
618
|
* from Neort's API response using the off-chain `provenance.offChainURI`
|
|
615
619
|
* shape produced by `buildUrlItem`.
|
|
616
620
|
*/
|