@feralfile/cli 2.3.0 → 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/dist/index.js CHANGED
@@ -28,6 +28,7 @@ const play_1 = require("./src/commands/play");
28
28
  const publish_1 = require("./src/commands/publish");
29
29
  const find_1 = require("./src/commands/find");
30
30
  const build_1 = require("./src/commands/build");
31
+ const enrich_1 = require("./src/commands/enrich");
31
32
  const config_1 = require("./src/commands/config");
32
33
  const ssh_1 = require("./src/commands/ssh");
33
34
  const device_1 = require("./src/commands/device");
@@ -55,6 +56,7 @@ program.addCommand(sign_1.signCommand);
55
56
  program.addCommand(play_1.playCommand);
56
57
  program.addCommand(publish_1.publishCommand);
57
58
  program.addCommand(find_1.findCommand);
59
+ program.addCommand(enrich_1.enrichCommand);
58
60
  program.addCommand(build_1.buildCommand);
59
61
  program.addCommand(config_1.configCommand);
60
62
  program.addCommand(ssh_1.sshCommand);
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feralfile/cli",
3
- "version": "2.3.0",
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",
@@ -0,0 +1,315 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.enrichCommand = void 0;
7
+ const commander_1 = require("commander");
8
+ const chalk_1 = __importDefault(require("chalk"));
9
+ const fs_1 = require("fs");
10
+ const crypto_1 = require("crypto");
11
+ const path_1 = require("path");
12
+ const enrich_playlist_1 = require("../utilities/enrich-playlist");
13
+ const playlist_verifier_1 = require("../utilities/playlist-verifier");
14
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
15
+ const { getNFTTokenInfoBatch } = require('../utilities/nft-indexer');
16
+ /**
17
+ * writePlaylistAtomically replaces a file only once the new bytes are safely on
18
+ * disk.
19
+ *
20
+ * The default destination is the input file, and a plain write truncates it
21
+ * first. Enrichment runs after a lookup that can take minutes while the indexer
22
+ * warms tokens, so the window between truncation and a complete write is a
23
+ * window in which an interruption, a full disk, or an I/O error leaves the
24
+ * curator with an empty or half-written playlist and no copy of what they had.
25
+ * A rename within the same directory is atomic, so the original file survives
26
+ * intact until the replacement is complete.
27
+ *
28
+ * The temporary file is created alongside the destination rather than in the
29
+ * system temp directory: rename is only atomic within a filesystem, and those
30
+ * are not guaranteed to be the same one.
31
+ */
32
+ async function writePlaylistAtomically(destination, contents, expectedDigest) {
33
+ // Follow a symlink to its target before replacing anything. rename() would
34
+ // replace the link itself, silently detaching a playlist that other paths
35
+ // reach through that name while reporting success.
36
+ let target = destination;
37
+ const existing = await fs_1.promises.lstat(destination).catch(() => null);
38
+ if (existing?.isSymbolicLink()) {
39
+ target = await fs_1.promises.realpath(destination);
40
+ }
41
+ const temporary = (0, path_1.join)((0, path_1.dirname)(target), `.${(0, path_1.basename)(target)}.${process.pid}.${(0, crypto_1.randomBytes)(4).toString('hex')}.tmp`);
42
+ // The destination's mode has to be known before the file exists, not after
43
+ // it holds the contents. Creating at the default 0o666-minus-umask and
44
+ // chmod'ing afterwards leaves the enriched playlist readable by other local
45
+ // users for the length of the write — short, but a disclosure window on a
46
+ // file the curator deliberately restricted.
47
+ const current = await fs_1.promises.stat(target).catch(() => null);
48
+ const mode = current ? current.mode & 0o7777 : undefined;
49
+ try {
50
+ // 'wx' fails rather than truncating if the name somehow exists, so a
51
+ // collision can never destroy another process's work in progress.
52
+ const handle = await fs_1.promises.open(temporary, 'wx', mode);
53
+ try {
54
+ // open() applies the mode through umask, which can strip bits the
55
+ // destination had. chmod is not subject to umask, so this restores the
56
+ // exact mode — still before any bytes are written.
57
+ if (mode !== undefined) {
58
+ await handle.chmod(mode);
59
+ }
60
+ // A replacement is a new inode, so it carries this process's ownership
61
+ // rather than the destination's. Silently reassigning a playlist that
62
+ // belongs to another user or a shared group can lock collaborators out
63
+ // of it, or widen who can reach it. Carry the ownership across, and when
64
+ // that is not permitted refuse the in-place replacement instead of
65
+ // completing it with different access than the file had.
66
+ if (current && (current.uid !== process.getuid?.() || current.gid !== process.getgid?.())) {
67
+ try {
68
+ await handle.chown(current.uid, current.gid);
69
+ }
70
+ catch {
71
+ await fs_1.promises.rm(temporary, { force: true }).catch(() => undefined);
72
+ throw new OwnershipError(target);
73
+ }
74
+ }
75
+ await handle.writeFile(contents);
76
+ // rename() is atomic against concurrent readers but says nothing about
77
+ // power loss: the directory entry can reach disk before the data it
78
+ // points at. Without this a crash mid-write can leave the playlist
79
+ // present and empty, which is the outcome the temporary file exists to
80
+ // prevent. Sync the contents first, then the directory entry after.
81
+ await handle.sync();
82
+ }
83
+ finally {
84
+ await handle.close();
85
+ }
86
+ // Checked here, as late as the sequence allows: everything expensive is
87
+ // already done, so the interval between this read and the rename below is
88
+ // a few syscalls rather than the minutes a lookup takes.
89
+ //
90
+ // It narrows the race; it cannot close it. Comparing and then replacing is
91
+ // not atomic against an editor that does not participate in any protocol,
92
+ // and nothing available in userspace makes it so — every tool that edits a
93
+ // file in place carries this. Passing no digest (a distinct --output) opts
94
+ // out entirely, which is the safe path when a file is being actively
95
+ // edited.
96
+ if (expectedDigest !== undefined) {
97
+ const current = await fs_1.promises.readFile(target).catch(() => null);
98
+ const digest = current && (0, crypto_1.createHash)('sha256').update(current).digest('hex');
99
+ if (digest !== expectedDigest) {
100
+ await fs_1.promises.rm(temporary, { force: true }).catch(() => undefined);
101
+ return false;
102
+ }
103
+ }
104
+ await fs_1.promises.rename(temporary, target);
105
+ await syncDirectory((0, path_1.dirname)(target));
106
+ return true;
107
+ }
108
+ catch (error) {
109
+ await fs_1.promises.rm(temporary, { force: true }).catch(() => undefined);
110
+ throw error;
111
+ }
112
+ }
113
+ /**
114
+ * OwnershipError marks a refusal to replace a file whose ownership this
115
+ * process cannot reproduce, so the caller can explain it rather than print a
116
+ * bare errno.
117
+ */
118
+ class OwnershipError extends Error {
119
+ target;
120
+ constructor(target) {
121
+ super(`cannot preserve ownership of ${target}`);
122
+ this.target = target;
123
+ this.name = 'OwnershipError';
124
+ }
125
+ }
126
+ /**
127
+ * isSameFile reports whether two paths name the same file on disk.
128
+ *
129
+ * Compares resolved paths rather than the strings the caller typed, so
130
+ * `-o ./playlist.json` against `playlist.json`, and a symlink against its
131
+ * target, are recognized as the in-place case they are.
132
+ */
133
+ async function isSameFile(a, b) {
134
+ if (a === b) {
135
+ return true;
136
+ }
137
+ const [left, right] = await Promise.all([
138
+ fs_1.promises.realpath(a).catch(() => null),
139
+ fs_1.promises.realpath(b).catch(() => null),
140
+ ]);
141
+ return left !== null && left === right;
142
+ }
143
+ /**
144
+ * syncDirectory flushes a directory entry so a rename survives power loss.
145
+ *
146
+ * Best-effort by design. Directory fsync is not portable — Windows rejects it
147
+ * outright — and a durability barrier that cannot be raised is not a reason to
148
+ * fail a write that has already succeeded. The data itself was synced before
149
+ * the rename, so the worst case here is the old name surviving a crash, not a
150
+ * corrupt file.
151
+ */
152
+ async function syncDirectory(directory) {
153
+ try {
154
+ const handle = await fs_1.promises.open(directory, 'r');
155
+ try {
156
+ await handle.sync();
157
+ }
158
+ finally {
159
+ await handle.close();
160
+ }
161
+ }
162
+ catch {
163
+ /* not supported on this platform or filesystem */
164
+ }
165
+ }
166
+ /**
167
+ * reportInvalid prints structural validation diagnostics and exits non-zero.
168
+ *
169
+ * Enrichment refuses to work on a document DP-1 rejects, and refuses to write
170
+ * one. Writing an invalid playlist would replace the operator's file with
171
+ * something sign, publish, and play all reject later, having reported success.
172
+ */
173
+ function reportInvalid(stage, result) {
174
+ console.error(chalk_1.default.red(`\n${stage}`));
175
+ if (result.error) {
176
+ console.error(chalk_1.default.dim(` ${result.error}`));
177
+ }
178
+ for (const detail of result.details ?? []) {
179
+ console.error(chalk_1.default.dim(` ${detail.path}: ${detail.message}`));
180
+ }
181
+ console.error(chalk_1.default.dim('\n The file was left unchanged.\n'));
182
+ process.exit(1);
183
+ }
184
+ /**
185
+ * SKIP_COPY explains each skip in the operator's terms rather than the code's.
186
+ * `no-provenance` is the one a person can act on, so it says what to add.
187
+ */
188
+ const SKIP_COPY = {
189
+ 'already-labelled': 'already has a manifest (use --force to replace)',
190
+ 'external-ref': 'carries an external ref, which outranks an inline manifest',
191
+ 'no-provenance': 'no provenance.contract chain/address/tokenId to look up',
192
+ 'ambiguous-chain': 'chain "evm" names a family; pass --assume-ethereum to assert the network',
193
+ // The indexer drops unresolved tokens from its response rather than
194
+ // reporting why, so there is no per-item reason to relay here.
195
+ 'not-indexed': 'the indexer returned nothing for it',
196
+ 'no-metadata': 'the indexer resolved it but has no artist, description, or still image',
197
+ };
198
+ exports.enrichCommand = new commander_1.Command('enrich')
199
+ .description('Add missing artist, title, and thumbnail metadata to a playlist')
200
+ .argument('<file>', 'Path to the DP-1 playlist file')
201
+ .option('-o, --output <filename>', 'Write here instead of overwriting the input')
202
+ .option('--force', 'Replace manifests that already exist', false)
203
+ .option('--assume-ethereum', 'Treat DP-1 "evm" coordinates as Ethereum. Without this they are skipped, ' +
204
+ 'because "evm" names a family and the wrong member yields another artwork.', false)
205
+ .option('-v, --verbose', 'Show detailed output', false)
206
+ .action(async (file, options) => {
207
+ try {
208
+ console.log(chalk_1.default.blue('\nEnrich playlist\n'));
209
+ // Hash the exact bytes parsed, not a stat taken afterwards: an edit
210
+ // between the read and the stat would leave the snapshot describing
211
+ // newer content while enrichment still worked from the older bytes.
212
+ const original = await fs_1.promises.readFile(file);
213
+ const originalDigest = (0, crypto_1.createHash)('sha256').update(original).digest('hex');
214
+ const playlist = JSON.parse(original.toString('utf-8'));
215
+ const total = Array.isArray(playlist.items) ? playlist.items.length : 0;
216
+ if (total === 0) {
217
+ console.error(chalk_1.default.red('That playlist has no items.'));
218
+ process.exit(1);
219
+ }
220
+ // Validate before spending minutes on indexer lookups for a document
221
+ // that was never going to be writable.
222
+ const before = await (0, playlist_verifier_1.validatePlaylist)(playlist);
223
+ if (!before.valid) {
224
+ reportInvalid('That playlist is not valid DP-1:', before);
225
+ }
226
+ // Warming previously-unseen tokens can take minutes, so a human watching
227
+ // this needs to see it move. Matches the progress line `find` prints.
228
+ let printedProgress = false;
229
+ const onProgress = (done, count) => {
230
+ printedProgress = true;
231
+ process.stdout.write(chalk_1.default.dim(`\r ${done}/${count} looked up...`));
232
+ };
233
+ // getNFTTokenInfoBatch is (tokens, duration, onProgress) — the second
234
+ // positional is DP-1 display seconds, not the callback. Enrichment never
235
+ // sets duration (the curator's timing is not ours to touch), so it is
236
+ // passed undefined and the callback goes third. find.ts carries the same
237
+ // warning; getting this wrong silently returns unusable results.
238
+ const lookup = (tokens, onProgressCallback) => getNFTTokenInfoBatch(tokens, undefined, onProgressCallback);
239
+ const result = await (0, enrich_playlist_1.enrichPlaylistManifests)(playlist, lookup, {
240
+ force: options.force,
241
+ assumeEthereum: options.assumeEthereum,
242
+ onProgress,
243
+ });
244
+ if (printedProgress) {
245
+ process.stdout.write('\n');
246
+ }
247
+ // Said before anything is written, so an operator who did not mean to
248
+ // assert the network still has the file they started with.
249
+ if (result.assumedEthereum > 0) {
250
+ console.log(chalk_1.default.yellow(`\n ${result.assumedEthereum} coordinate(s) gave chain "evm" and were looked up on` +
251
+ `\n Ethereum because --assume-ethereum was passed. An L2 work sharing an` +
252
+ `\n address and token id with an Ethereum one would take the wrong metadata.`));
253
+ }
254
+ const destination = options.output ?? file;
255
+ // --output names a file the caller expects to find afterwards, so it is
256
+ // written even when nothing was enriched. Overwriting the input on a
257
+ // no-op would be pure risk for no gain, so that case still writes
258
+ // nothing.
259
+ const shouldWrite = result.enriched > 0 || options.output !== undefined;
260
+ if (shouldWrite) {
261
+ // Re-validate the enriched candidate. An inline manifest is schema-
262
+ // checked the same way a fetched one is (playlists extension §3.6), so
263
+ // a malformed manifest from the indexer must not reach the file.
264
+ const after = await (0, playlist_verifier_1.validatePlaylist)(result.playlist);
265
+ if (!after.valid) {
266
+ reportInvalid('Enrichment produced an invalid playlist:', after);
267
+ }
268
+ const replaced = await writePlaylistAtomically(destination, JSON.stringify(result.playlist, null, 2), (await isSameFile(file, destination)) ? originalDigest : undefined);
269
+ if (!replaced) {
270
+ console.error(chalk_1.default.red('\nThat playlist changed while the lookup ran.'));
271
+ console.error(chalk_1.default.dim(' Enrichment was computed from the earlier version, so writing it\n' +
272
+ ' would discard whatever changed. Nothing was written. Re-run to\n' +
273
+ ' enrich the current file, or pass -o to write somewhere else.\n'));
274
+ process.exit(1);
275
+ }
276
+ }
277
+ console.log(result.enriched > 0
278
+ ? chalk_1.default.green(`\n${result.enriched} of ${total} item(s) enriched`)
279
+ : chalk_1.default.yellow('\nNothing to enrich'));
280
+ if (shouldWrite) {
281
+ console.log(chalk_1.default.dim(` Output: ${destination}`));
282
+ }
283
+ if (result.skipped.length > 0) {
284
+ const shown = options.verbose ? result.skipped : result.skipped.slice(0, 10);
285
+ console.log(chalk_1.default.dim(`\n Skipped ${result.skipped.length}:`));
286
+ for (const skip of shown) {
287
+ console.log(chalk_1.default.dim(` ${skip.title} — ${SKIP_COPY[skip.reason]}`));
288
+ }
289
+ if (shown.length < result.skipped.length) {
290
+ console.log(chalk_1.default.dim(` ...and ${result.skipped.length - shown.length} more (-v to list)`));
291
+ }
292
+ }
293
+ // Say this loudly. A signed playlist that silently lost its envelope
294
+ // fails at the device, which is the worst place to discover it.
295
+ if (result.signatureInvalidated) {
296
+ console.log(chalk_1.default.yellow('\n Signature removed — the document changed.'));
297
+ console.log(chalk_1.default.dim(` Re-sign before playing: ff-cli sign ${destination}`));
298
+ }
299
+ console.log();
300
+ }
301
+ catch (error) {
302
+ if (error instanceof OwnershipError) {
303
+ console.error(chalk_1.default.red('\nThat playlist belongs to another user or group.'));
304
+ console.error(chalk_1.default.dim(` Replacing it in place would hand it to you and could lock others out,\n` +
305
+ ` so nothing was written. Write elsewhere instead:\n` +
306
+ ` ff-cli enrich ${error.target} -o enriched.json\n`));
307
+ process.exit(1);
308
+ }
309
+ console.error(chalk_1.default.red('\nError:'), error.message);
310
+ if (options.verbose) {
311
+ console.error(chalk_1.default.dim(error.stack));
312
+ }
313
+ process.exit(1);
314
+ }
315
+ });
@@ -0,0 +1,459 @@
1
+ "use strict";
2
+ /**
3
+ * Fills in missing inline Ref Manifests on an existing DP-1 playlist.
4
+ *
5
+ * `find` and `build` attach an `inlineManifest` to every item they create, so
6
+ * playlists this CLI authors end to end are already labelled. Playlists that
7
+ * arrive any other way are not: items copied out of another playlist, items
8
+ * hand-written against a known contract, items inherited from a document
9
+ * predating §3.6. Those carry a `source` and a `provenance` block and nothing
10
+ * else, and there was no way to repair them short of editing JSON by hand.
11
+ *
12
+ * What the gap costs is visible on a device. The FF1 tombstone resolves its
13
+ * label through `ref` -> `inlineManifest` -> `item.metadata` and falls back to
14
+ * the item title, so an unlabelled item shows a title with no artist line. The
15
+ * app's grid is worse: it can rasterize a thumbnail from a direct image, video,
16
+ * or SVG source, but not from a live HTML work, so every generative item in an
17
+ * unlabelled playlist renders as an empty tile. A playlist of computational art
18
+ * is mostly live HTML, which is exactly the case that looks broken.
19
+ *
20
+ * Enrichment is additive and narrow on purpose. It writes `inlineManifest` and
21
+ * nothing else: `source`, `duration`, `id`, and `display` are the curator's
22
+ * decisions and are left exactly as found, even when the indexer disagrees. An
23
+ * item the indexer cannot resolve is reported, never guessed at — a fabricated
24
+ * artist line in a signed document is worse than a missing one.
25
+ */
26
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
27
+ if (k2 === undefined) k2 = k;
28
+ var desc = Object.getOwnPropertyDescriptor(m, k);
29
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
30
+ desc = { enumerable: true, get: function() { return m[k]; } };
31
+ }
32
+ Object.defineProperty(o, k2, desc);
33
+ }) : (function(o, m, k, k2) {
34
+ if (k2 === undefined) k2 = k;
35
+ o[k2] = m[k];
36
+ }));
37
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
38
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
39
+ }) : function(o, v) {
40
+ o["default"] = v;
41
+ });
42
+ var __importStar = (this && this.__importStar) || (function () {
43
+ var ownKeys = function(o) {
44
+ ownKeys = Object.getOwnPropertyNames || function (o) {
45
+ var ar = [];
46
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
47
+ return ar;
48
+ };
49
+ return ownKeys(o);
50
+ };
51
+ return function (mod) {
52
+ if (mod && mod.__esModule) return mod;
53
+ var result = {};
54
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
55
+ __setModuleDefault(result, mod);
56
+ return result;
57
+ };
58
+ })();
59
+ Object.defineProperty(exports, "__esModule", { value: true });
60
+ exports.enrichPlaylistManifests = enrichPlaylistManifests;
61
+ const node_crypto_1 = require("node:crypto");
62
+ const logger = __importStar(require("../logger"));
63
+ /**
64
+ * CHAIN_ALIASES translates a DP-1 chain name into the indexer's, outbound.
65
+ *
66
+ * DP-1 names the EVM family `evm` (core spec §6: "evm" | "tezos" | "bitmark" |
67
+ * "other"), because the protocol describes a class of chains. The FF indexer
68
+ * names the same thing `ethereum`, because it queries a specific one. Without
69
+ * this every EVM item comes back unresolved, and the failure looks like a
70
+ * missing artwork rather than a vocabulary mismatch.
71
+ *
72
+ * Unlisted values pass through so the indexer, not this table, decides what it
73
+ * supports.
74
+ */
75
+ const CHAIN_ALIASES = {
76
+ evm: 'ethereum',
77
+ };
78
+ /**
79
+ * `evm` names a family, and this maps it to one member of that family.
80
+ *
81
+ * DP-1 core §6 offers "evm" | "tezos" | "bitmark" | "other" and carries no
82
+ * network identity, while the indexer maps Ethereum, Polygon, Arbitrum,
83
+ * Optimism, Base, and Zora all back to `evm`. So a DP-1 coordinate cannot say
84
+ * which EVM network it means, and neither can the response.
85
+ *
86
+ * Ethereum is chosen because it is the only EVM network this client can reach:
87
+ * buildTokenCID maps ethereum to eip155:1 and tezos to tezos:mainnet, and
88
+ * throws for everything else. A --chain flag would not widen that — it would
89
+ * only move the throw. The residual risk is narrow and real: an L2 work whose
90
+ * address and token id also exist on Ethereum would be enriched with the
91
+ * Ethereum work's metadata, and nothing in the response could reveal it.
92
+ *
93
+ * The count is surfaced so the assumption is stated at the point of use rather
94
+ * than buried here. Widening it belongs upstream, in the indexer client.
95
+ */
96
+ const AMBIGUOUS_CHAIN = 'evm';
97
+ /**
98
+ * One CLI invocation stamps one manifest timestamp, matching ref-manifest.ts:
99
+ * a run that synthesizes several manifests should not write several clocks.
100
+ */
101
+ const MANIFEST_CREATED = new Date().toISOString();
102
+ /**
103
+ * canonicalChain collapses both vocabularies onto one name so a coordinate
104
+ * sent to the indexer and a coordinate read back off its response compare
105
+ * equal. The direction is deliberately the reverse of CHAIN_ALIASES: this one
106
+ * is for matching, that one is for querying.
107
+ */
108
+ function canonicalChain(chain) {
109
+ const lower = chain.trim().toLowerCase();
110
+ return lower === 'ethereum' ? 'evm' : lower;
111
+ }
112
+ /**
113
+ * coordinateKeyOf builds the correlation key for a provenance block, or null
114
+ * when the block cannot identify a single work.
115
+ *
116
+ * The address is lowercased because the indexer echoes it checksummed while
117
+ * callers typically write it lowercase, and the two must still match.
118
+ */
119
+ function coordinateKeyOf(provenance) {
120
+ const contract = provenance?.contract;
121
+ if (!contract) {
122
+ return null;
123
+ }
124
+ const { chain, address, tokenId } = contract;
125
+ if (!chain || !address || tokenId === undefined || tokenId === null) {
126
+ return null;
127
+ }
128
+ const token = String(tokenId).trim();
129
+ if (token.length === 0) {
130
+ return null;
131
+ }
132
+ return `${canonicalChain(chain)}:${address.trim().toLowerCase()}:${token}`;
133
+ }
134
+ /**
135
+ * describeItem names an item for operator output, preferring its title.
136
+ */
137
+ function describeItem(item, index) {
138
+ const title = typeof item.title === 'string' ? item.title.trim() : '';
139
+ return title.length > 0 ? title : `item ${index + 1}`;
140
+ }
141
+ /**
142
+ * coordinateFor extracts a lookup key from an item, or null when the item
143
+ * carries no usable on-chain coordinate.
144
+ *
145
+ * All three parts are required. A contract with no token id identifies a
146
+ * collection rather than a work, and resolving that would attach some other
147
+ * item's manifest to this one.
148
+ */
149
+ function coordinateFor(item) {
150
+ const contract = item.provenance?.contract;
151
+ if (!contract) {
152
+ return null;
153
+ }
154
+ const { chain, address, tokenId } = contract;
155
+ if (!chain || !address || tokenId === undefined || tokenId === null) {
156
+ return null;
157
+ }
158
+ const token = String(tokenId).trim();
159
+ if (token.length === 0) {
160
+ return null;
161
+ }
162
+ const normalized = chain.trim().toLowerCase();
163
+ return {
164
+ chain: CHAIN_ALIASES[normalized] ?? normalized,
165
+ contractAddress: address,
166
+ tokenId: token,
167
+ };
168
+ }
169
+ /**
170
+ * stillFromIndexerSource recovers a thumbnail the manifest builder suppressed.
171
+ *
172
+ * `resolveStillUri` blanks a still that equals the source it was given, on the
173
+ * reasonable ground that pointing a thumbnail at the artwork itself adds
174
+ * payload and no information. But the source it was given is the one the
175
+ * INDEXER chose, not the one the curator kept. For a static work the indexer
176
+ * commonly selects the still as the source, so the thumbnail is dropped —
177
+ * and when the curator's item points at a live HTML generator instead, the
178
+ * item ends up with no still at all. That is precisely the case this command
179
+ * exists to repair: a live work the app cannot rasterize, whose grid tile
180
+ * stays empty.
181
+ *
182
+ * Recovering it is the inverse of the suppression. When the indexer's source
183
+ * differs from the item's and is an http(s) URL, that source IS the still that
184
+ * was elided, so it becomes the thumbnail relative to the item's own source.
185
+ */
186
+ function stillFromIndexerSource(item, resolved) {
187
+ const indexerSource = typeof resolved.source === 'string' ? resolved.source.trim() : '';
188
+ const itemSource = typeof item.source === 'string' ? item.source.trim() : '';
189
+ if (!indexerSource || indexerSource === itemSource) {
190
+ return '';
191
+ }
192
+ if (!indexerSource.startsWith('http://') && !indexerSource.startsWith('https://')) {
193
+ return '';
194
+ }
195
+ // The indexer's source is not necessarily a still. getBestMediaUrl prefers
196
+ // display.animation_url and media assets over display.image_url, so it is
197
+ // frequently a live HTML rendition — and writing one into the thumbnail slot
198
+ // is worse than leaving the slot empty: the grid still cannot rasterize it,
199
+ // and the item now claims a still it does not have.
200
+ //
201
+ // Only a URL that is demonstrably an image is used. The indexer exposes no
202
+ // separately identified still on the item this command receives, so the file
203
+ // extension is the evidence available; carrying a real image field through
204
+ // the batch result would be the better fix and belongs upstream.
205
+ return looksLikeImage(indexerSource) ? indexerSource : '';
206
+ }
207
+ /**
208
+ * Media the app can draw a grid tile from, matching the types
209
+ * playlist-builder.js already recognizes. SVG and video belong here: the app
210
+ * rasterizes both, and excluding them left works with the empty tile this
211
+ * command exists to remove. HTML is deliberately absent — an unrasterizable
212
+ * page in the thumbnail slot is worse than an empty slot, because the item
213
+ * then claims a still it does not have.
214
+ */
215
+ const RASTERIZABLE_EXTENSIONS = [
216
+ '.png',
217
+ '.jpg',
218
+ '.jpeg',
219
+ '.gif',
220
+ '.webp',
221
+ '.avif',
222
+ '.bmp',
223
+ '.svg',
224
+ '.mp4',
225
+ '.webm',
226
+ ];
227
+ /**
228
+ * looksLikeImage tests a URL's path extension, ignoring query and fragment.
229
+ */
230
+ function looksLikeImage(url) {
231
+ let pathname = url;
232
+ try {
233
+ pathname = new URL(url).pathname;
234
+ }
235
+ catch {
236
+ pathname = url.split('?')[0].split('#')[0];
237
+ }
238
+ const lower = pathname.toLowerCase();
239
+ return RASTERIZABLE_EXTENSIONS.some((extension) => lower.endsWith(extension));
240
+ }
241
+ /**
242
+ * hasThumbnail reports whether a manifest already carries a default still.
243
+ */
244
+ function hasThumbnail(manifest) {
245
+ const thumbnails = manifest.metadata
246
+ ?.thumbnails;
247
+ return typeof thumbnails?.default?.uri === 'string' && thumbnails.default.uri.length > 0;
248
+ }
249
+ /**
250
+ * manifestFor returns the manifest to write onto an item, or undefined when
251
+ * the indexer resolved the token without building one.
252
+ *
253
+ * The curator's own title wins. `item.title` survives enrichment untouched,
254
+ * but the tombstone reads the manifest first, so attaching an indexer title
255
+ * that disagrees would change the displayed label while appearing to preserve
256
+ * it — the indexer writes "Pre-Process #0" where a curator wrote
257
+ * "Pre-Process". Everything else in the manifest (artist, description,
258
+ * thumbnails) is what enrichment exists to fetch and is taken as given.
259
+ */
260
+ function manifestFor(item, resolved) {
261
+ const still = stillFromIndexerSource(item, resolved);
262
+ let manifest = resolved.inlineManifest;
263
+ let rekey = false;
264
+ if (manifest && still && !hasThumbnail(manifest)) {
265
+ manifest = {
266
+ ...manifest,
267
+ metadata: {
268
+ ...(manifest.metadata ?? {}),
269
+ thumbnails: { default: { uri: still } },
270
+ },
271
+ };
272
+ rekey = true;
273
+ }
274
+ else if (!manifest && still) {
275
+ // Nothing was emitted because the still was the only content the token
276
+ // had, and it had been suppressed. A thumbnail-only manifest is worth its
277
+ // payload in a way a title-only one is not: it is the difference between
278
+ // a grid tile and an empty square.
279
+ const synthesizedTitle = typeof item.title === 'string' ? item.title.trim() : '';
280
+ manifest = {
281
+ refVersion: '1.1.0',
282
+ id: derivedManifestId('', `${synthesizedTitle}\n${still}`),
283
+ created: MANIFEST_CREATED,
284
+ locale: 'en',
285
+ metadata: {
286
+ ...(synthesizedTitle ? { title: synthesizedTitle } : {}),
287
+ thumbnails: { default: { uri: still } },
288
+ },
289
+ };
290
+ }
291
+ if (!manifest) {
292
+ return undefined;
293
+ }
294
+ const metadata = manifest.metadata ?? {};
295
+ const curatorTitle = typeof item.title === 'string' ? item.title.trim() : '';
296
+ const overrideTitle = curatorTitle.length > 0 && metadata.title !== curatorTitle;
297
+ // The id is derived from the finished payload every time, not only when this
298
+ // command changed something. The indexer keys its id on the token
299
+ // coordinate, so every rendition of one artwork carries the same id whatever
300
+ // its content — and under --force the indexer may itself return a different
301
+ // artist or description under that unchanged id. Deriving from the payload
302
+ // is the only rule that holds in every case: identical content keeps one
303
+ // identity, differing content never shares one.
304
+ //
305
+ // The original id is folded in, so the result stays anchored to the token
306
+ // rather than floating free of it, and stays deterministic across runs.
307
+ //
308
+ // `rekey` is read only to record that a thumbnail was recovered; the
309
+ // derivation below covers it either way.
310
+ void rekey;
311
+ const finalMetadata = overrideTitle ? { ...metadata, title: curatorTitle } : metadata;
312
+ return {
313
+ ...manifest,
314
+ id: derivedManifestId(typeof manifest.id === 'string' ? manifest.id : '', stableStringify(finalMetadata)),
315
+ metadata: finalMetadata,
316
+ };
317
+ }
318
+ /**
319
+ * stableStringify serializes a value with object keys in sorted order, so the
320
+ * same content hashes the same however it was assembled.
321
+ */
322
+ function stableStringify(value) {
323
+ if (value === null || typeof value !== 'object') {
324
+ return JSON.stringify(value) ?? 'null';
325
+ }
326
+ if (Array.isArray(value)) {
327
+ return `[${value.map(stableStringify).join(',')}]`;
328
+ }
329
+ const entries = Object.entries(value)
330
+ .filter(([, entryValue]) => entryValue !== undefined)
331
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
332
+ .map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`);
333
+ return `{${entries.join(',')}}`;
334
+ }
335
+ /**
336
+ * derivedManifestId produces a stable id for a manifest whose title this
337
+ * command replaced, shaped as a UUID so it reads like every other manifest id.
338
+ */
339
+ function derivedManifestId(originalId, title) {
340
+ const digest = (0, node_crypto_1.createHash)('sha256').update(`${originalId}\n${title}`).digest('hex');
341
+ return [
342
+ digest.slice(0, 8),
343
+ digest.slice(8, 12),
344
+ digest.slice(12, 16),
345
+ digest.slice(16, 20),
346
+ digest.slice(20, 32),
347
+ ].join('-');
348
+ }
349
+ /**
350
+ * enrichPlaylistManifests attaches an inline Ref Manifest to every item that
351
+ * lacks one and can be resolved on chain.
352
+ *
353
+ * The playlist is mutated in place and returned; callers that need the
354
+ * original should read it twice. Items are looked up in one batch so the
355
+ * indexer sees a single burst rather than a request per item — warming
356
+ * previously-unseen tokens is the slow path, and it parallelizes internally.
357
+ *
358
+ * Signatures are dropped when anything changed, in both the DP-1 v1.1.0
359
+ * `signatures[]` form and the legacy flat `signature` this repo still verifies.
360
+ * A signature covers the document bytes, and an enriched document is a
361
+ * different document; leaving a stale envelope in place would produce a file
362
+ * that fails verification at the device with no explanation of why.
363
+ */
364
+ async function enrichPlaylistManifests(playlist, lookup, options = {}) {
365
+ const items = Array.isArray(playlist.items) ? playlist.items : [];
366
+ const skipped = [];
367
+ const pending = [];
368
+ // One lookup per distinct artwork, not per item. getNFTTokenInfoBatch runs a
369
+ // lookup for each token it is handed, and a token the indexer has not seen
370
+ // triggers an indexing job and a poll that can take minutes. A playlist that
371
+ // deliberately repeats a work — the same piece at two points in a loop —
372
+ // would otherwise submit that job once per appearance.
373
+ const coordinates = new Map();
374
+ items.forEach((item, index) => {
375
+ // An item with a `ref` is already labelled, by a manifest this command
376
+ // cannot see. Ref outranks inlineManifest in the resolution order, so
377
+ // enriching one writes a manifest the device will ignore while still
378
+ // invalidating the signature: damage with no visible change.
379
+ //
380
+ // --force does not override this. Making the inline manifest win would
381
+ // mean deleting the curator's `ref` and `refHash`, which is a different
382
+ // and larger decision than filling in missing metadata — and one that
383
+ // silently discards the integrity hash the remote manifest is checked
384
+ // against. If that conversion is ever wanted it should be its own flag,
385
+ // named for what it does.
386
+ if (typeof item.ref === 'string' && item.ref.trim().length > 0) {
387
+ skipped.push({ index, title: describeItem(item, index), reason: 'external-ref' });
388
+ return;
389
+ }
390
+ if (item.inlineManifest && !options.force) {
391
+ skipped.push({ index, title: describeItem(item, index), reason: 'already-labelled' });
392
+ return;
393
+ }
394
+ const coordinate = coordinateFor(item);
395
+ const key = coordinateKeyOf(item.provenance);
396
+ if (!coordinate || !key) {
397
+ skipped.push({ index, title: describeItem(item, index), reason: 'no-provenance' });
398
+ return;
399
+ }
400
+ // `evm` names a family and the response cannot narrow it, so enriching one
401
+ // on the assumption of Ethereum can attach a different artwork's artist,
402
+ // description, and still — into a document the curator then signs. The
403
+ // operator has to assert the network; the command will not assume it.
404
+ if (key.startsWith(`${AMBIGUOUS_CHAIN}:`) && !options.assumeEthereum) {
405
+ skipped.push({ index, title: describeItem(item, index), reason: 'ambiguous-chain' });
406
+ return;
407
+ }
408
+ pending.push({ index, key });
409
+ if (!coordinates.has(key)) {
410
+ coordinates.set(key, coordinate);
411
+ }
412
+ });
413
+ const assumedEthereum = [...coordinates.keys()].filter((key) => key.startsWith(`${AMBIGUOUS_CHAIN}:`)).length;
414
+ if (pending.length === 0) {
415
+ return { playlist, enriched: 0, skipped, signatureInvalidated: false, assumedEthereum: 0 };
416
+ }
417
+ const resolved = await lookup([...coordinates.values()], options.onProgress);
418
+ // Index what came back by its own coordinate. A response carrying no usable
419
+ // provenance cannot be attributed to any request, so it is dropped rather
420
+ // than guessed at; the affected item then reports as not-indexed, which is
421
+ // exactly what happened.
422
+ const byCoordinate = new Map();
423
+ for (const entry of resolved) {
424
+ const key = coordinateKeyOf(entry.provenance);
425
+ if (key && !byCoordinate.has(key)) {
426
+ byCoordinate.set(key, entry);
427
+ }
428
+ }
429
+ let enriched = 0;
430
+ for (const entry of pending) {
431
+ const item = items[entry.index];
432
+ const match = byCoordinate.get(entry.key);
433
+ const manifest = match ? manifestFor(item, match) : undefined;
434
+ if (!manifest) {
435
+ // The indexer resolving a token and the indexer having anything worth
436
+ // attaching are different outcomes. buildInlineManifestForToken returns
437
+ // undefined when it has only a title, because a title-only manifest adds
438
+ // payload to every transfer without adding information. Reporting that
439
+ // as "the indexer returned nothing" would be false.
440
+ skipped.push({
441
+ index: entry.index,
442
+ title: describeItem(item, entry.index),
443
+ reason: match ? 'no-metadata' : 'not-indexed',
444
+ });
445
+ continue;
446
+ }
447
+ item.inlineManifest = manifest;
448
+ enriched += 1;
449
+ }
450
+ const hadSignature = playlist.signatures !== undefined || playlist.signature !== undefined;
451
+ const signatureInvalidated = enriched > 0 && hadSignature;
452
+ if (signatureInvalidated) {
453
+ delete playlist.signatures;
454
+ delete playlist.signature;
455
+ logger.debug('[Enrich] Dropped signatures: the enriched document must be re-signed.');
456
+ }
457
+ skipped.sort((a, b) => a.index - b.index);
458
+ return { playlist, enriched, skipped, signatureInvalidated, assumedEthereum };
459
+ }
@@ -4,10 +4,19 @@
4
4
  * to retrieve comprehensive token information.
5
5
  */
6
6
  const { PlaylistItemBuilder, ProvenanceBuilder, ContractBuilder } = require('dp1-js');
7
- // Production by default. INDEXER_API_URL exists so tests can point the
8
- // client at a local mock, the same way RASTER_API_URL already works — it
9
- // is not user-facing configuration, which stays removed (initializeIndexer).
10
- const GRAPHQL_ENDPOINT = process.env.INDEXER_API_URL || 'https://indexer.feralfile.com/graphql';
7
+ // The production endpoint is hardcoded, as PROJECT_SPEC states, and stays
8
+ // that way. INDEXER_API_URL is a test hook, not configuration: the
9
+ // find-command suite spawns the CLI as a child process, so the only way to
10
+ // point it at a local mock is through the environment.
11
+ //
12
+ // It is gated on NODE_ENV=test because index.ts loads dotenv at startup. An
13
+ // ungated read would mean a stray INDEXER_API_URL in someone's .env, or an
14
+ // inherited shell value, silently redirecting every token lookup and indexing
15
+ // mutation — a configuration surface nobody documented and no one would think
16
+ // to check. The gate keeps the hook usable without creating that surface.
17
+ const GRAPHQL_ENDPOINT = process.env.NODE_ENV === 'test' && process.env.INDEXER_API_URL
18
+ ? process.env.INDEXER_API_URL
19
+ : 'https://indexer.feralfile.com/graphql';
11
20
  const { fetchWithTimeout } = require('./http');
12
21
  const logger = require('../logger');
13
22
  const { applyTimingToItemBuilder, buildDefaultDisplay } = require('./playlist-builder');
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
@@ -197,6 +197,128 @@ nowhere to host a manifest document. Behavior worth knowing downstream:
197
197
  - An inline manifest has no `refHash`; its integrity comes from the playlist
198
198
  signature.
199
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
+
200
322
  ## Deterministic-first behavior
201
323
 
202
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:
package/docs/README.md CHANGED
@@ -104,6 +104,9 @@ Both paths run the same deterministic pipeline: fetch metadata, assemble a DP-1
104
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)
105
105
  - Verse forms: `/items/ethereum/{contract}/{tokenId}` and `/series/{slug}`
106
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`
107
110
  - `publish <file>` – Publish a playlist to a feed server (runs `verify` before upload and rejects unsigned or broken playlists)
108
111
  - Options: `-s, --server <index>` (server index if multiple configured)
109
112
  - `ssh <enable|disable>` – Manage SSH access on an FF1 device
@@ -176,6 +179,41 @@ cat params.json | ff-cli build -o playlist.json
176
179
  }
177
180
  ```
178
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
+
179
217
  ### Validate, sign, and play
180
218
 
181
219
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feralfile/cli",
3
- "version": "2.3.0",
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",