@feralfile/cli 2.2.3 → 2.4.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 +2 -11
- package/dist/index.js +2 -0
- package/dist/package.json +3 -3
- package/dist/src/commands/enrich.js +315 -0
- package/dist/src/utilities/enrich-playlist.js +459 -0
- package/dist/src/utilities/feed-fetcher.js +34 -4
- package/dist/src/utilities/nft-indexer.js +50 -20
- package/dist/src/utilities/playlist-builder.js +187 -209
- package/dist/src/utilities/ref-manifest.js +226 -0
- package/docs/EXAMPLES.md +42 -0
- package/docs/PROJECT_SPEC.md +199 -1
- package/docs/README.md +39 -9
- package/docs/RELEASING.md +3 -42
- package/package.json +3 -3
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Inline Ref Manifest construction for DP-1 playlist items.
|
|
4
|
+
*
|
|
5
|
+
* DP-1 Playlist Extension §3.6 lets an item carry a complete Ref Manifest
|
|
6
|
+
* inline instead of behind a `ref` URL. That matters here because ff-cli has
|
|
7
|
+
* nowhere to host a manifest document: it emits a playlist file and hands it
|
|
8
|
+
* to an FF1 device. Before dp1-js 2.3.0 there was no item-level home for the
|
|
9
|
+
* artwork description, artist, or still image the indexer already returns, so
|
|
10
|
+
* `nft-indexer.js` discarded all three. Inline manifests give them a home.
|
|
11
|
+
*
|
|
12
|
+
* This module is a leaf on purpose: it imports only `dp1-js` and the logger,
|
|
13
|
+
* never another `src/utilities/` module. `nft-indexer.js` already requires
|
|
14
|
+
* `playlist-builder.js`, so putting this logic in either of them would make
|
|
15
|
+
* the require graph a question that has to be re-answered on every edit.
|
|
16
|
+
*
|
|
17
|
+
* Integrity note: an inline manifest is covered by the playlist signature, so
|
|
18
|
+
* unlike a ref-fetched manifest it carries no `refHash`. Everything written
|
|
19
|
+
* here therefore ends up inside a signed document — which is why the mapping
|
|
20
|
+
* below refuses to synthesize fields the CLI cannot actually substantiate.
|
|
21
|
+
*/
|
|
22
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
23
|
+
if (k2 === undefined) k2 = k;
|
|
24
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
25
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
26
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
27
|
+
}
|
|
28
|
+
Object.defineProperty(o, k2, desc);
|
|
29
|
+
}) : (function(o, m, k, k2) {
|
|
30
|
+
if (k2 === undefined) k2 = k;
|
|
31
|
+
o[k2] = m[k];
|
|
32
|
+
}));
|
|
33
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
34
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
35
|
+
}) : function(o, v) {
|
|
36
|
+
o["default"] = v;
|
|
37
|
+
});
|
|
38
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
39
|
+
var ownKeys = function(o) {
|
|
40
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
41
|
+
var ar = [];
|
|
42
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
43
|
+
return ar;
|
|
44
|
+
};
|
|
45
|
+
return ownKeys(o);
|
|
46
|
+
};
|
|
47
|
+
return function (mod) {
|
|
48
|
+
if (mod && mod.__esModule) return mod;
|
|
49
|
+
var result = {};
|
|
50
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
51
|
+
__setModuleDefault(result, mod);
|
|
52
|
+
return result;
|
|
53
|
+
};
|
|
54
|
+
})();
|
|
55
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
56
|
+
exports.buildInlineManifestForToken = buildInlineManifestForToken;
|
|
57
|
+
const node_crypto_1 = require("node:crypto");
|
|
58
|
+
const dp1_js_1 = require("dp1-js");
|
|
59
|
+
const logger = __importStar(require("../logger"));
|
|
60
|
+
/**
|
|
61
|
+
* DP-1 version this CLI emits. RefManifestBuilder falls back to "0.1.0" when
|
|
62
|
+
* `refVersion` is unset, which would contradict the `dpVersion: 1.1.0`
|
|
63
|
+
* envelope playlist-builder.js builds around these items.
|
|
64
|
+
*/
|
|
65
|
+
const REF_VERSION = '1.1.0';
|
|
66
|
+
/**
|
|
67
|
+
* The Feral File indexer exposes no language tag on `display`, and the schema
|
|
68
|
+
* requires a locale. Every string we map comes from that one untagged source,
|
|
69
|
+
* so "en" is an assumption, not an observation. If the indexer ever returns a
|
|
70
|
+
* language, thread it through rather than widening this constant.
|
|
71
|
+
*/
|
|
72
|
+
const DEFAULT_LOCALE = 'en';
|
|
73
|
+
/**
|
|
74
|
+
* One CLI invocation stamps one manifest timestamp.
|
|
75
|
+
*
|
|
76
|
+
* Freezing at module load keeps a 500-token `build` from writing 500 different
|
|
77
|
+
* clocks across items of the same playlist, and keeps two runs byte-diffable
|
|
78
|
+
* except for this single value. The playlist envelope's own `created` already
|
|
79
|
+
* varies the same way (playlist-builder.js pins it only under the test-only
|
|
80
|
+
* deterministicMode). This assumes a short-lived process: do not reuse this
|
|
81
|
+
* module from a daemon without threading `created` in explicitly.
|
|
82
|
+
*/
|
|
83
|
+
const MANIFEST_CREATED_AT = new Date().toISOString();
|
|
84
|
+
/** Namespace prefix keeping manifest ids from aliasing PlaylistItem ids. */
|
|
85
|
+
const ID_NAMESPACE = 'dp1:ref-manifest:v1';
|
|
86
|
+
/** uuidFromSeed formats a sha256 digest UUID-style, mirroring nft-indexer.js. */
|
|
87
|
+
function uuidFromSeed(seed) {
|
|
88
|
+
const hash = (0, node_crypto_1.createHash)('sha256').update(seed).digest('hex');
|
|
89
|
+
return [
|
|
90
|
+
hash.slice(0, 8),
|
|
91
|
+
hash.slice(8, 12),
|
|
92
|
+
hash.slice(12, 16),
|
|
93
|
+
hash.slice(16, 20),
|
|
94
|
+
hash.slice(20, 32),
|
|
95
|
+
].join('-');
|
|
96
|
+
}
|
|
97
|
+
/** text trims a possibly-absent value down to a non-empty string. */
|
|
98
|
+
function text(value) {
|
|
99
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* resolveStillUri picks the still image that belongs in `metadata.thumbnails`.
|
|
103
|
+
*
|
|
104
|
+
* Returns an empty string unless the candidate is genuinely additional
|
|
105
|
+
* information. Three rejections, each for a distinct reason:
|
|
106
|
+
*
|
|
107
|
+
* - equal to `sourceUrl`: for a static token `image.url` IS the source
|
|
108
|
+
* (see getBestMediaUrl in nft-indexer.js), so a thumbnail identical to it
|
|
109
|
+
* is pure duplication inside a signed payload;
|
|
110
|
+
* - `data:`: both item factories already refuse data-URI sources, and
|
|
111
|
+
* inlining base64 into every playlist would dwarf the playlist itself;
|
|
112
|
+
* - anything not http(s): `Thumbnail.uri` carries `format: "uri"`, and an
|
|
113
|
+
* `ipfs://` value is not resolvable by the FF1 display path today.
|
|
114
|
+
*/
|
|
115
|
+
function resolveStillUri(token, sourceUrl) {
|
|
116
|
+
const image = token.image;
|
|
117
|
+
const candidate = typeof image === 'string' ? text(image) : text(image?.thumbnail) || text(image?.url);
|
|
118
|
+
if (!candidate || candidate === sourceUrl) {
|
|
119
|
+
return '';
|
|
120
|
+
}
|
|
121
|
+
if (!candidate.startsWith('http://') && !candidate.startsWith('https://')) {
|
|
122
|
+
return '';
|
|
123
|
+
}
|
|
124
|
+
return candidate;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* buildInlineManifestForToken builds the Ref Manifest for one playlist item,
|
|
128
|
+
* or returns undefined when the token carries nothing worth carrying.
|
|
129
|
+
*
|
|
130
|
+
* Emission threshold: at least one of description, artists, or thumbnails must
|
|
131
|
+
* survive normalization. `title` deliberately does NOT count — it is already a
|
|
132
|
+
* first-class PlaylistItem field, so a title-only manifest adds payload to
|
|
133
|
+
* every FF1 transfer and every signed envelope while telling a consumer
|
|
134
|
+
* nothing new. The threshold is expressed here, once, so the item factories
|
|
135
|
+
* stay a two-line call and cannot drift apart.
|
|
136
|
+
*
|
|
137
|
+
* Deliberately not mapped, each for a reason that should survive future edits:
|
|
138
|
+
* - `creditLine`: the schema calls it copyright and credit information. The
|
|
139
|
+
* CLI has no such string; deriving one from `token.owner` would assert a
|
|
140
|
+
* rights claim it has no basis for, inside a signed document.
|
|
141
|
+
* - `tags`: `metadata.attributes` is hard-coded to [] upstream, and NFT
|
|
142
|
+
* traits are key/value pairs — flattening them yields garbage like
|
|
143
|
+
* ["Blue", "Hat"].
|
|
144
|
+
* - `collection`: no Metadata field accepts it, and upstream computes its
|
|
145
|
+
* name by splitting the token name on '#' — string surgery, not identity.
|
|
146
|
+
* - `owner`: not the artist, not credit, and stale the moment the token
|
|
147
|
+
* trades.
|
|
148
|
+
* - `controls`: display preferences already live on `item.display`, written
|
|
149
|
+
* from the DP-1 §4.1 timing heuristics. Duplicating them here would create
|
|
150
|
+
* two sources of truth with no rule for which wins.
|
|
151
|
+
* - `i18n`: no localized strings exist anywhere in this pipeline.
|
|
152
|
+
*
|
|
153
|
+
* Never throws. A manifest is decoration; nft-indexer.js turns a builder throw
|
|
154
|
+
* into `{ success: false }`, which the build pipeline counts as a dropped
|
|
155
|
+
* token — so a malformed thumbnail must not silently shorten a user's
|
|
156
|
+
* playlist.
|
|
157
|
+
*
|
|
158
|
+
* @param token - Token in the internal standard format
|
|
159
|
+
* @param opts - Resolved item source URL, and an optional created override
|
|
160
|
+
* @returns A schema-valid RefManifest, or undefined when below the threshold
|
|
161
|
+
*/
|
|
162
|
+
function buildInlineManifestForToken(token, opts = {}) {
|
|
163
|
+
if (!token || typeof token !== 'object') {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
const sourceUrl = text(opts.sourceUrl);
|
|
167
|
+
const title = text(token.name);
|
|
168
|
+
const description = text(token.description);
|
|
169
|
+
const artistName = text(token.metadata?.artistName);
|
|
170
|
+
const stillUri = resolveStillUri(token, sourceUrl);
|
|
171
|
+
if (!description && !artistName && !stillUri) {
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
const metadata = new dp1_js_1.MetadataBuilder();
|
|
176
|
+
if (title) {
|
|
177
|
+
metadata.title(title);
|
|
178
|
+
}
|
|
179
|
+
if (description) {
|
|
180
|
+
metadata.description(description);
|
|
181
|
+
}
|
|
182
|
+
if (artistName) {
|
|
183
|
+
metadata.addArtist({ name: artistName });
|
|
184
|
+
}
|
|
185
|
+
if (stillUri) {
|
|
186
|
+
// A plain literal, not ThumbnailBuilder: `w`/`h` became optional in
|
|
187
|
+
// dp1-js 2.3.0 and we never know the intrinsic pixel dimensions, so the
|
|
188
|
+
// emitted shape must be exactly { uri } with no undefined-valued keys.
|
|
189
|
+
metadata.addThumbnail('default', { uri: stillUri });
|
|
190
|
+
}
|
|
191
|
+
return new dp1_js_1.RefManifestBuilder()
|
|
192
|
+
.refVersion(REF_VERSION)
|
|
193
|
+
.id(manifestId(token, sourceUrl))
|
|
194
|
+
.created(opts.created ?? MANIFEST_CREATED_AT)
|
|
195
|
+
.locale(DEFAULT_LOCALE)
|
|
196
|
+
.metadata(metadata)
|
|
197
|
+
.build();
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
logger.debug('[Ref Manifest] Skipping inline manifest:', {
|
|
201
|
+
contractAddress: token.contractAddress,
|
|
202
|
+
tokenId: token.tokenId,
|
|
203
|
+
error: error instanceof Error ? error.message : String(error),
|
|
204
|
+
});
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* manifestId derives a stable id from token coordinates.
|
|
210
|
+
*
|
|
211
|
+
* The manifest describes the artwork, not one playlist entry, so the id must
|
|
212
|
+
* be identical across runs and across playlists — it is a cache key. The
|
|
213
|
+
* namespace prefix is load-bearing: nft-indexer.js derives the PlaylistItem id
|
|
214
|
+
* from the bare `${contract}-${tokenId}` string, and a consumer keying a
|
|
215
|
+
* manifest cache must never alias a playlist-item cache.
|
|
216
|
+
*/
|
|
217
|
+
function manifestId(token, sourceUrl) {
|
|
218
|
+
const contract = text(token.contractAddress);
|
|
219
|
+
const tokenId = text(String(token.tokenId ?? ''));
|
|
220
|
+
if (contract && tokenId) {
|
|
221
|
+
return uuidFromSeed(`${ID_NAMESPACE}:${text(token.chain)}:${contract}:${tokenId}`);
|
|
222
|
+
}
|
|
223
|
+
// Off-indexer tokens (caller-supplied objects) have no coordinates; the
|
|
224
|
+
// source URL is the only stable identity available.
|
|
225
|
+
return uuidFromSeed(`${ID_NAMESPACE}:src:${sourceUrl}`);
|
|
226
|
+
}
|
package/docs/EXAMPLES.md
CHANGED
|
@@ -232,6 +232,44 @@ JSON
|
|
|
232
232
|
npm run dev -- build /tmp/mixed.json -o playlist-mixed.json -v
|
|
233
233
|
```
|
|
234
234
|
|
|
235
|
+
## Enrich an existing playlist
|
|
236
|
+
|
|
237
|
+
A playlist whose items carry `provenance` but no `inlineManifest` labels
|
|
238
|
+
title-only on the FF1 tombstone, and shows empty tiles in the app for every
|
|
239
|
+
live HTML work. `enrich` repairs it in place.
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
ff-cli enrich playlist.json
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
```text
|
|
246
|
+
Enrich playlist
|
|
247
|
+
|
|
248
|
+
18/18 looked up...
|
|
249
|
+
|
|
250
|
+
18 of 18 item(s) enriched
|
|
251
|
+
Output: playlist.json
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Items the indexer cannot resolve are listed rather than guessed at:
|
|
255
|
+
|
|
256
|
+
```text
|
|
257
|
+
Nothing to enrich
|
|
258
|
+
|
|
259
|
+
Skipped 2:
|
|
260
|
+
Kim Asendorf — PXL NET — no provenance.contract chain/address/tokenId to look up
|
|
261
|
+
Untitled — the indexer returned nothing for it
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Enrichment changes the document, so a signed playlist loses its envelope and
|
|
265
|
+
has to be re-signed:
|
|
266
|
+
|
|
267
|
+
```bash
|
|
268
|
+
ff-cli enrich playlist.json
|
|
269
|
+
ff-cli sign playlist.json
|
|
270
|
+
ff-cli play playlist.json -d "living room"
|
|
271
|
+
```
|
|
272
|
+
|
|
235
273
|
## Validate / Sign / Play
|
|
236
274
|
|
|
237
275
|
```bash
|
|
@@ -254,6 +292,10 @@ npm run dev -- play signed.json -d "Living Room Display"
|
|
|
254
292
|
# Play a hosted DP-1 playlist
|
|
255
293
|
npm run dev -- play "https://cdn.example.com/playlist.json"
|
|
256
294
|
|
|
295
|
+
# Play a playlist straight from a DP-1 feed server (the URL the feed hands out)
|
|
296
|
+
# Wallet-signed (eip191) documents need --skip-verify until #103 lands
|
|
297
|
+
npm run dev -- play "https://feed.feralfile.com/api/v1/playlists/<slug>" --skip-verify
|
|
298
|
+
|
|
257
299
|
# Play a media URL directly
|
|
258
300
|
npm run dev -- play "https://example.com/video.mp4"
|
|
259
301
|
|
package/docs/PROJECT_SPEC.md
CHANGED
|
@@ -105,7 +105,7 @@ Based on the code today, the CLI is responsible for:
|
|
|
105
105
|
- resolving marketplace URLs, on-chain coordinates, and wallet addresses into playlists (`find`)
|
|
106
106
|
- running feed fetches, address queries, contract-based NFT queries, domain resolution, playlist building, verification, publishing, and playback
|
|
107
107
|
- supporting a deterministic build path from structured JSON (`build`)
|
|
108
|
-
- building DP-1 playlist envelopes from NFT metadata or direct media URLs
|
|
108
|
+
- building DP-1 playlist envelopes from NFT metadata or direct media URLs via `dp1-js` document and leaf builders (so constructed playlists stay schema-conformant), including inline Ref Manifests on items that carry indexer metadata
|
|
109
109
|
- validating and verifying playlist structure and signatures
|
|
110
110
|
- signing playlists when a private key is configured
|
|
111
111
|
- publishing validated playlists to configured feed servers
|
|
@@ -174,6 +174,151 @@ Today the CLI groups into these workflow areas:
|
|
|
174
174
|
|
|
175
175
|
- `ssh enable|disable`
|
|
176
176
|
|
|
177
|
+
### Inline Ref Manifests
|
|
178
|
+
|
|
179
|
+
Playlist items carry a full Ref Manifest inline (DP-1 Playlist Extension §3.6)
|
|
180
|
+
rather than behind a `ref` URL, because the CLI emits a playlist file and has
|
|
181
|
+
nowhere to host a manifest document. Behavior worth knowing downstream:
|
|
182
|
+
|
|
183
|
+
- An item gets a manifest whenever the indexer supplies a description, an
|
|
184
|
+
artist, or a still image distinct from the item source. A token carrying only
|
|
185
|
+
a title gets none — the item already has a `title` field, so a title-only
|
|
186
|
+
manifest would add payload to every device transfer and signed envelope
|
|
187
|
+
without adding information.
|
|
188
|
+
- Manifest ids are derived from the token's chain, contract, and token id, so
|
|
189
|
+
they are stable across runs and across playlists — that stable `id` is what
|
|
190
|
+
identifies the manifest. `created` is frozen once per CLI invocation, so every
|
|
191
|
+
item in one build shares a single timestamp; it moves between builds, which is
|
|
192
|
+
expected (see "What determinism does not mean here").
|
|
193
|
+
- Items carry no `ref`. `ref` is a URI to an externally hosted Ref Manifest,
|
|
194
|
+
and requires `refHash` over HTTPS; a locally built playlist has nowhere to
|
|
195
|
+
host one, which is the case §3.6 exists to serve. A still image distinct from
|
|
196
|
+
the source is carried at `inlineManifest.metadata.thumbnails.default.uri`.
|
|
197
|
+
- An inline manifest has no `refHash`; its integrity comes from the playlist
|
|
198
|
+
signature.
|
|
199
|
+
|
|
200
|
+
### Enriching a playlist the CLI did not build
|
|
201
|
+
|
|
202
|
+
`find` and `build` attach a manifest as they create each item. A playlist that
|
|
203
|
+
arrives another way — assembled by hand, copied out of another document, or
|
|
204
|
+
written before §3.6 existed — has items with a `source` and a `provenance` block
|
|
205
|
+
and nothing else. `enrich` is the repair path for those.
|
|
206
|
+
|
|
207
|
+
- Enrichment is keyed on `provenance.contract` (chain, address, token id), not
|
|
208
|
+
on `source`. A source URL is one rendition of a work and several renditions
|
|
209
|
+
share one; the coordinate is what identifies the artwork.
|
|
210
|
+
- It writes `inlineManifest` and nothing else. `source`, `duration`, `title`,
|
|
211
|
+
`id`, and `display` are curator decisions and survive untouched even when the
|
|
212
|
+
indexer disagrees with them.
|
|
213
|
+
- An item the indexer cannot resolve is reported, never synthesized. The same
|
|
214
|
+
rule the manifest mapping follows applies here: the CLI does not invent fields
|
|
215
|
+
it cannot substantiate, and an invented artist line inside a signed document
|
|
216
|
+
is worse than an absent one.
|
|
217
|
+
- DP-1 names the EVM family `evm`; the FF indexer names it `ethereum`. Enrich
|
|
218
|
+
translates between the two. Unlisted chain names pass through so the indexer
|
|
219
|
+
decides what it supports.
|
|
220
|
+
- Enrichment changes the document, so both the `signatures[]` envelope and the
|
|
221
|
+
legacy flat `signature` are removed and the playlist must be re-signed.
|
|
222
|
+
Leaving a stale envelope in place would produce a file that fails
|
|
223
|
+
verification at the device with no explanation.
|
|
224
|
+
- An item carrying a `ref` is already labelled by a manifest the CLI cannot
|
|
225
|
+
see, and `ref` outranks `inlineManifest` in the resolution order, so
|
|
226
|
+
enrichment skips it. `--force` does not override that: making the inline
|
|
227
|
+
manifest win would mean deleting the curator's `ref` and `refHash`, which
|
|
228
|
+
discards the integrity hash the remote manifest is checked against. That
|
|
229
|
+
conversion, if ever wanted, belongs behind its own flag.
|
|
230
|
+
- Enrichment looks each distinct coordinate up once, however many items share
|
|
231
|
+
it. A token the indexer has not seen triggers an indexing job and a poll that
|
|
232
|
+
can take minutes, and a playlist that repeats a work would otherwise submit
|
|
233
|
+
that job once per appearance.
|
|
234
|
+
- Overriding an item's title changes the manifest's content, so it also changes
|
|
235
|
+
the manifest's `id`. The indexer derives that id from the token coordinate,
|
|
236
|
+
so two items of one artwork receive the same one; leaving it alone while
|
|
237
|
+
changing the title would produce two documents claiming to be the same cached
|
|
238
|
+
manifest. The replacement is derived from the original id and the title, so
|
|
239
|
+
it is deterministic across runs.
|
|
240
|
+
- The indexer resolving a token and having something worth attaching are
|
|
241
|
+
different outcomes: `buildInlineManifestForToken` returns nothing when it has
|
|
242
|
+
only a title, since a title-only manifest adds payload without adding
|
|
243
|
+
information. Enrichment reports those separately from tokens it could not
|
|
244
|
+
resolve at all.
|
|
245
|
+
- A still the manifest builder suppressed is recovered against the item's own
|
|
246
|
+
source. `resolveStillUri` blanks a thumbnail equal to the source it was
|
|
247
|
+
handed, which is the source the *indexer* chose; for a static work that is
|
|
248
|
+
often the still itself. When the curator kept a live HTML source instead, the
|
|
249
|
+
item would otherwise end up with no thumbnail — the empty grid tile this
|
|
250
|
+
command exists to remove. When the indexer's source differs from the item's
|
|
251
|
+
and is http(s) **and is media the app can rasterize**, it is that suppressed
|
|
252
|
+
still
|
|
253
|
+
and becomes the thumbnail. The type check matters: `getBestMediaUrl` prefers
|
|
254
|
+
`display.animation_url` and media assets over `display.image_url`, so the
|
|
255
|
+
indexer's source is frequently a live HTML rendition, and putting one in the
|
|
256
|
+
thumbnail slot is worse than leaving it empty — the grid still cannot
|
|
257
|
+
rasterize it and the item now claims a still it does not have. The predicate
|
|
258
|
+
matches the media types `playlist-builder.js` already recognizes, images plus
|
|
259
|
+
SVG and video, and rejects HTML. A
|
|
260
|
+
thumbnail-only manifest is emitted where none was, unlike a title-only one:
|
|
261
|
+
it is the difference between a tile and an empty square.
|
|
262
|
+
- `evm` names a family, and enrichment refuses to guess which member. DP-1 §6
|
|
263
|
+
carries no network identity, and the indexer maps Ethereum, Polygon,
|
|
264
|
+
Arbitrum, Optimism, Base, and Zora all back to `evm`, so neither the request
|
|
265
|
+
nor the response can say which network is meant. Ethereum is chosen because
|
|
266
|
+
it is the only EVM network this client can reach — `buildTokenCID` maps
|
|
267
|
+
ethereum to `eip155:1` and tezos to `tezos:mainnet` and throws for anything
|
|
268
|
+
else. The residual risk is narrow: an L2 work whose address and token id also
|
|
269
|
+
exist on Ethereum would take the Ethereum work's metadata. The command
|
|
270
|
+
is the operator's to make: `--assume-ethereum` asserts it, and without that
|
|
271
|
+
flag `evm` items are skipped with a reason that names the flag. The warning
|
|
272
|
+
prints before anything is written, so an operator who did not mean to assert
|
|
273
|
+
it still has the file they started with. Reaching an actual L2 belongs in the
|
|
274
|
+
indexer client, which maps only `ethereum` and `tezos` today.
|
|
275
|
+
- In-place writes refuse to run over an input that changed while the lookup
|
|
276
|
+
was in flight. The comparison is a hash of the bytes actually parsed, against
|
|
277
|
+
the resolved write target, so `-o` naming the input by another spelling and a
|
|
278
|
+
symlink pointing at it are both recognized as the in-place case. It runs
|
|
279
|
+
immediately before the rename, so the exposed interval is a few syscalls
|
|
280
|
+
rather than the minutes a lookup takes. It narrows the race and does not
|
|
281
|
+
close it: compare-then-replace is not atomic against an editor participating
|
|
282
|
+
in no protocol, and no userspace sequence makes it so. Writing to a distinct
|
|
283
|
+
`--output` opts out of in-place replacement entirely, which is the right
|
|
284
|
+
choice when a file is being actively edited.
|
|
285
|
+
- An attached manifest's id is derived from its whole finished metadata block,
|
|
286
|
+
every time rather than only when this command changed something. The indexer
|
|
287
|
+
keys its id on the token coordinate, so every rendition of one artwork
|
|
288
|
+
carries the same id whatever its content, and under `--force` the indexer may
|
|
289
|
+
itself return a different artist or description under that unchanged id.
|
|
290
|
+
Deriving from the payload is the only rule that holds in every case:
|
|
291
|
+
identical content keeps one identity, differing content never shares one. The
|
|
292
|
+
original id is folded in so the result stays anchored to the token and stays
|
|
293
|
+
deterministic across runs.
|
|
294
|
+
- In-place writes refuse to run over an input that changed while the lookup
|
|
295
|
+
was in flight. The lookup can take minutes and the default destination is the
|
|
296
|
+
input, so a curator's edit or re-sign in that window would otherwise be
|
|
297
|
+
overwritten by a result computed from older bytes.
|
|
298
|
+
- In-place writes are atomic and crash-durable: the contents are synced before
|
|
299
|
+
the rename and the containing directory after it, so a power loss cannot
|
|
300
|
+
leave the destination name pointing at incomplete data. The directory sync is
|
|
301
|
+
best-effort, since it is not portable.
|
|
302
|
+
- In-place writes are atomic: the candidate goes to a temporary file beside the
|
|
303
|
+
destination and is renamed over it, so an interruption cannot leave a
|
|
304
|
+
truncated playlist. A symlinked destination is followed to its target rather
|
|
305
|
+
than replaced, and the existing file's permission mode is read before the
|
|
306
|
+
temporary file is created and applied to it before any bytes are written, so
|
|
307
|
+
a curator-restricted playlist is never briefly world-readable mid-write. The
|
|
308
|
+
destination's owner and group are carried across too, since a replacement is
|
|
309
|
+
a new inode and would otherwise take this process's ownership — silently
|
|
310
|
+
reassigning a shared playlist. Where that cannot be done, the in-place
|
|
311
|
+
replacement is refused and `--output` is offered instead. Extended ACLs are
|
|
312
|
+
not preserved, because Node exposes no portable way to read them; a playlist
|
|
313
|
+
carrying them should be enriched through `--output`.
|
|
314
|
+
- `--output` names a file the caller expects to exist afterwards, so it is
|
|
315
|
+
written even when nothing was enriched. A no-op without `--output` writes
|
|
316
|
+
nothing rather than rewriting the input for no gain.
|
|
317
|
+
- The playlist is validated before lookup and the enriched candidate is
|
|
318
|
+
validated before writing. On either failure the diagnostics are printed and
|
|
319
|
+
the file is left untouched, rather than replaced with a document that sign,
|
|
320
|
+
publish, and play would each reject later.
|
|
321
|
+
|
|
177
322
|
## Deterministic-first behavior
|
|
178
323
|
|
|
179
324
|
The CLI is deterministic end to end. There is no natural-language interface; any natural-language layer lives in the user's coding agent, which drives these commands:
|
|
@@ -182,6 +327,59 @@ The CLI is deterministic end to end. There is no natural-language interface; any
|
|
|
182
327
|
- utilities perform the real work and are the source of truth for output correctness
|
|
183
328
|
- invalid or malformed outputs fail validation rather than being passed through
|
|
184
329
|
|
|
330
|
+
### What determinism does not mean here
|
|
331
|
+
|
|
332
|
+
It does **not** mean two builds of the same input produce identical bytes or an
|
|
333
|
+
identical signature. Every citation below is from
|
|
334
|
+
[`display-protocol/dp1`](https://github.com/display-protocol/dp1) at core
|
|
335
|
+
v1.1.0, so a reader can check each claim rather than take this section's word
|
|
336
|
+
for it.
|
|
337
|
+
|
|
338
|
+
**Timestamps are wall-clock, by definition.** The schemas define both creation
|
|
339
|
+
fields as the moment the document was made:
|
|
340
|
+
|
|
341
|
+
- `core/v1.1.0/schemas/playlist.json` — `created`: *"ISO 8601 timestamp when the
|
|
342
|
+
playlist was created"*
|
|
343
|
+
- `core/v1.1.0/schemas/ref-manifest.json` — `created`: *"RFC3339 timestamp when
|
|
344
|
+
the manifest was created"*
|
|
345
|
+
|
|
346
|
+
Building the same playlist twice creates two documents at two different times,
|
|
347
|
+
so those fields legitimately differ.
|
|
348
|
+
|
|
349
|
+
**Different bytes therefore mean a different signature, by construction.**
|
|
350
|
+
`core/v1.1.0/spec.md` §7: *"Each signature is computed over the canonical form
|
|
351
|
+
of the entire playlist (excluding the `signature` and `signatures` fields
|
|
352
|
+
themselves)"*, where *"Canonical form ≡ JSON Canonicalization Scheme (JCS), RFC
|
|
353
|
+
8785"*. A differing `created` changes the canonical bytes, which changes the
|
|
354
|
+
payload hash, which changes the signature. That is the signature reporting what
|
|
355
|
+
actually happened — not a defect to engineer away.
|
|
356
|
+
|
|
357
|
+
**Identity is carried by `id`, not by bytes.**
|
|
358
|
+
`core/v1.1.0/schemas/ref-manifest.json` describes `id` as *"Unique identifier
|
|
359
|
+
for the manifest (for caching)"*, echoed in the envelope example at
|
|
360
|
+
`core/v1.1.0/ref-manifest.md` §3: `"id": "ref-7c3d", // unique identifier (for
|
|
361
|
+
caching)`. Two manifests sharing an `id` are the same manifest. This CLI derives
|
|
362
|
+
manifest ids from token coordinates precisely so identity stays stable while
|
|
363
|
+
timestamps move.
|
|
364
|
+
|
|
365
|
+
**DP-1 states no reproducible-build requirement.** Searching `core/v1.1.0/spec.md`
|
|
366
|
+
and `core/v1.1.0/ref-manifest.md` for reproducibility language turns up the word
|
|
367
|
+
"deterministic" in exactly two places, neither about build-time byte stability:
|
|
368
|
+
|
|
369
|
+
- `spec.md` §5, *"Deterministic Reproduction (`repro`)"* — reproducing the
|
|
370
|
+
**rendering** of code-based artwork, via `engineVersion`, `seed`,
|
|
371
|
+
`assetsSHA256`, and `frameHash`.
|
|
372
|
+
- `ref-manifest.md` §2 Goals, *"Deterministic merging: predictable behavior
|
|
373
|
+
across devices and players"* — the **merge order** in §7 (player defaults →
|
|
374
|
+
playlist defaults → `ref.controls` → runtime overrides).
|
|
375
|
+
|
|
376
|
+
Neither imposes byte-for-byte stability across builds, and no schema field
|
|
377
|
+
requires `created` to be immutable across regenerations of a document.
|
|
378
|
+
|
|
379
|
+
`buildDP1Playlist` accepts `deterministicMode` with `fixedTimestamp` and
|
|
380
|
+
`fixedId`. Those exist to pin the envelope for tests and are not used by any
|
|
381
|
+
command.
|
|
382
|
+
|
|
185
383
|
## Trust, protocol, and rights assumptions
|
|
186
384
|
|
|
187
385
|
Important constraints from the broader FF system:
|
package/docs/README.md
CHANGED
|
@@ -12,14 +12,6 @@ npm i -g @feralfile/cli
|
|
|
12
12
|
|
|
13
13
|
`npm` and `npx` require **Node.js 22 or newer** (see `package.json` `engines`). When a release raises the Node floor, that is a **breaking** change for Node 18/20 users; the GitHub Release for that version should say so explicitly (see `./RELEASING.md` for maintainer guidance).
|
|
14
14
|
|
|
15
|
-
## Install (curl)
|
|
16
|
-
|
|
17
|
-
```bash
|
|
18
|
-
curl -fsSL https://feralfile.com/ff-cli-install | bash
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
Installs a prebuilt bundle for macOS/Linux. Node.js 22 or newer must still be available in `PATH`; the bundle avoids a global npm install, not the Node runtime requirement.
|
|
22
|
-
|
|
23
15
|
## Configure
|
|
24
16
|
|
|
25
17
|
```bash
|
|
@@ -112,6 +104,9 @@ Both paths run the same deterministic pipeline: fetch metadata, assemble a DP-1
|
|
|
112
104
|
- OpenSea forms: `/assets/ethereum/{contract}/{tokenId}`, `/item/ethereum/{contract}/{tokenId}`, and `/collection/{slug}` (Ethereum collections; the slug is resolved from the public collection page, no API key needed)
|
|
113
105
|
- Verse forms: `/items/ethereum/{contract}/{tokenId}` and `/series/{slug}`
|
|
114
106
|
- Options: `-o, --output <path>`, `-l, --limit <n>`, `-p, --play`, `-d, --device <name>`, `--publish`, `-s, --server <index>`, `-y, --yes`, `--skip-verify`
|
|
107
|
+
- `enrich <file>` – Add missing artist, title, and thumbnail metadata to a playlist that already exists. Resolves each item by its `provenance.contract` and writes an inline Ref Manifest; `source`, `duration`, `title`, and `id` are never touched. Drops the signature when it changes anything, so re-sign afterwards
|
|
108
|
+
- DP-1 `evm` names a chain family rather than a network, so those items are skipped unless `--assume-ethereum` asserts which one they are. Getting that wrong attaches another artwork's metadata
|
|
109
|
+
- Options: `-o, --output <file>`, `--force` (replace existing manifests), `--assume-ethereum`, `-v, --verbose`
|
|
115
110
|
- `publish <file>` – Publish a playlist to a feed server (runs `verify` before upload and rejects unsigned or broken playlists)
|
|
116
111
|
- Options: `-s, --server <index>` (server index if multiple configured)
|
|
117
112
|
- `ssh <enable|disable>` – Manage SSH access on an FF1 device
|
|
@@ -184,6 +179,41 @@ cat params.json | ff-cli build -o playlist.json
|
|
|
184
179
|
}
|
|
185
180
|
```
|
|
186
181
|
|
|
182
|
+
### Enrich a playlist that came from somewhere else
|
|
183
|
+
|
|
184
|
+
`find` and `build` label every item they create. A playlist assembled by hand,
|
|
185
|
+
copied from another document, or written before inline manifests existed has no
|
|
186
|
+
`inlineManifest`, and on a device that shows as a tombstone with no artist line
|
|
187
|
+
and an empty tile in the app's grid — the app can draw a thumbnail from an
|
|
188
|
+
image, video, or SVG source, but not from a live HTML work.
|
|
189
|
+
|
|
190
|
+
`ff-cli enrich` fills that in from the indexer, keyed on each item's
|
|
191
|
+
`provenance.contract`:
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
ff-cli enrich playlist.json --assume-ethereum
|
|
195
|
+
ff-cli enrich playlist.json -o labelled.json
|
|
196
|
+
ff-cli enrich playlist.json --force # replace existing manifests
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`--assume-ethereum` is needed for most playlists. DP-1 records an EVM
|
|
200
|
+
coordinate as `chain: "evm"`, which names Ethereum, Polygon, Arbitrum,
|
|
201
|
+
Optimism, Base and Zora alike, and the indexer's answer collapses back to the
|
|
202
|
+
same word. Enrichment will not pick one for you, because the wrong member
|
|
203
|
+
returns a different artwork's artist and still — into a document you then sign.
|
|
204
|
+
|
|
205
|
+
It writes `inlineManifest` and nothing else. Items with no
|
|
206
|
+
`provenance.contract` chain, address, and token id cannot be looked up and are
|
|
207
|
+
reported rather than guessed at, and items carrying an external `ref` are left
|
|
208
|
+
alone because `ref` outranks an inline manifest at the device. The playlist is
|
|
209
|
+
validated before and after; on failure the file is left untouched. Enriching
|
|
210
|
+
changes the document, so the signature is removed and the playlist must be
|
|
211
|
+
re-signed before playing:
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
ff-cli enrich playlist.json && ff-cli sign playlist.json
|
|
215
|
+
```
|
|
216
|
+
|
|
187
217
|
### Validate, sign, and play
|
|
188
218
|
|
|
189
219
|
```bash
|
|
@@ -302,5 +332,5 @@ Setup preserves existing devices when adding new ones. See selection rules and e
|
|
|
302
332
|
## Links
|
|
303
333
|
|
|
304
334
|
- Examples: `./EXAMPLES.md`
|
|
305
|
-
-
|
|
335
|
+
- Releasing: `./RELEASING.md`
|
|
306
336
|
- DP1 spec: `https://github.com/display-protocol/dp1`
|
package/docs/RELEASING.md
CHANGED
|
@@ -7,7 +7,7 @@ How releases happen, how to cut one, and what to do when things go sideways.
|
|
|
7
7
|
1. **Bump `package.json` version** on `main` (or via a `chore: bump version to X.Y.Z` PR — the project has used both patterns).
|
|
8
8
|
2. **Push to `main`** so the tag created in step 3 points at the bump commit.
|
|
9
9
|
3. **Create a GitHub Release** with the tag equal to the version (e.g. tag `1.2.0` → `"version": "1.2.0"`). Use `gh release create 1.2.0 --title "v1.2.0" --notes "..."` or the web UI.
|
|
10
|
-
4. **The `release.yml` workflow fires automatically**, verifies tag == `package.json` version, runs CI, publishes to npm via OIDC,
|
|
10
|
+
4. **The `release.yml` workflow fires automatically**, verifies tag == `package.json` version, runs CI, publishes to npm via OIDC, and verifies the package landed on npm.
|
|
11
11
|
|
|
12
12
|
That's it. No tokens, no manual `npm publish`. The whole thing takes ~4 minutes end-to-end.
|
|
13
13
|
|
|
@@ -45,35 +45,12 @@ You will probably never do this again for `@feralfile/cli`, but if you ever publ
|
|
|
45
45
|
## GitHub Actions workflows
|
|
46
46
|
|
|
47
47
|
- **`ci.yml`** — formatting, lint, tests, build on Node 22 across ubuntu/macos/windows. Called by `release.yml` and runs on PRs / pushes to `main`.
|
|
48
|
-
- **`
|
|
49
|
-
- **`release.yml`** — orchestrates a release. Verifies version, runs CI, publishes to npm (Node 24 + OIDC + provenance), builds binaries, uploads release assets, verifies the package and assets are live. Triggers on published GitHub Releases or manual dispatch (beta only).
|
|
48
|
+
- **`release.yml`** — orchestrates a release. Verifies version, runs CI, publishes to npm (Node 24 + OIDC + provenance), and verifies the package is live. Triggers on published GitHub Releases or manual dispatch (beta only).
|
|
50
49
|
- **`codeql.yml`**, **`dependency-review.yml`** — security scanning.
|
|
51
50
|
|
|
52
|
-
## Binary release assets
|
|
53
|
-
|
|
54
|
-
The curl installer (`https://feralfile.com/ff-cli-install`) downloads prebuilt binaries from GitHub Releases. The release workflow builds and uploads them automatically. To build one locally:
|
|
55
|
-
|
|
56
|
-
**macOS / Linux:**
|
|
57
|
-
|
|
58
|
-
```bash
|
|
59
|
-
./scripts/release/build-asset.sh
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
**Windows (PowerShell):**
|
|
63
|
-
|
|
64
|
-
```powershell
|
|
65
|
-
.\scripts\release\build-asset-windows.ps1
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
Produces (names vary by OS/arch):
|
|
69
|
-
|
|
70
|
-
- `release/ff-cli-darwin-arm64.tar.gz` (and `.sha256`) on macOS
|
|
71
|
-
- `release/ff-cli-linux-x64.tar.gz` (and `.sha256`) on Linux
|
|
72
|
-
- `release/ff-cli-windows-x64.zip` (and `.sha256`) on Windows
|
|
73
|
-
|
|
74
51
|
## Release notes and breaking changes
|
|
75
52
|
|
|
76
|
-
GitHub Release text (and any user-facing summary you publish with the version) should state compatibility changes in plain language. **Do not rely on `package.json` `engines` alone**; npm
|
|
53
|
+
GitHub Release text (and any user-facing summary you publish with the version) should state compatibility changes in plain language. **Do not rely on `package.json` `engines` alone**; npm surfaces it inconsistently, and operators skim release notes first.
|
|
77
54
|
|
|
78
55
|
### Node.js engine floor (breaking)
|
|
79
56
|
|
|
@@ -89,22 +66,6 @@ GitHub Release text (and any user-facing summary you publish with the version) s
|
|
|
89
66
|
|
|
90
67
|
Later releases only need to repeat this block if the engine floor changes again.
|
|
91
68
|
|
|
92
|
-
## Installer redirect
|
|
93
|
-
|
|
94
|
-
`https://feralfile.com/ff-cli-install` should redirect to:
|
|
95
|
-
|
|
96
|
-
```
|
|
97
|
-
https://raw.githubusercontent.com/feral-file/ff-cli/main/scripts/install.sh
|
|
98
|
-
```
|
|
99
|
-
|
|
100
|
-
The installer script then fetches the release assets from GitHub Releases.
|
|
101
|
-
|
|
102
|
-
## Environment overrides
|
|
103
|
-
|
|
104
|
-
- `FF_CLI_VERSION`: overrides the version label in logs
|
|
105
|
-
- `FF_CLI_NODE_VERSION`: Reserved in script headers for future use; current CI, npm `engines`, and release wrappers assume **Node.js 22+** (required by `dp1-js`).
|
|
106
|
-
- `FF_CLI_OUTPUT_DIR`: output directory (default: `./release`)
|
|
107
|
-
|
|
108
69
|
## Troubleshooting
|
|
109
70
|
|
|
110
71
|
### `404 Not Found - PUT https://registry.npmjs.org/@feralfile%2fcli`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feralfile/cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.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",
|
|
@@ -58,14 +58,14 @@
|
|
|
58
58
|
"author": "",
|
|
59
59
|
"license": "MIT",
|
|
60
60
|
"dependencies": {
|
|
61
|
-
"@napi-rs/keyring": "1.3.0",
|
|
62
61
|
"@feralfile/source-resolver": "^1.0.0",
|
|
62
|
+
"@napi-rs/keyring": "1.3.0",
|
|
63
63
|
"axios": "^1.6.2",
|
|
64
64
|
"bonjour-service": "^1.3.0",
|
|
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.3.1",
|
|
69
69
|
"ff1": "^1.0.0",
|
|
70
70
|
"fuzzysort": "^3.1.0",
|
|
71
71
|
"viem": "^2.38.2"
|