@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.
@@ -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
 
@@ -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
- - Release assets: `./RELEASING.md`
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, builds binary assets, uploads them to the release, and verifies the package landed on npm.
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
- - **`build.yml`** — builds prebuilt binaries (macOS/Linux/Windows) and uploads them as workflow artifacts. Triggered manually or from `release.yml`.
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 and installers surface it inconsistently, and operators skim release notes first.
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.2.3",
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.0.0",
68
+ "dp1-js": "2.3.1",
69
69
  "ff1": "^1.0.0",
70
70
  "fuzzysort": "^3.1.0",
71
71
  "viem": "^2.38.2"