@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 +2 -0
- package/dist/package.json +1 -1
- package/dist/src/commands/enrich.js +315 -0
- package/dist/src/utilities/enrich-playlist.js +459 -0
- package/dist/src/utilities/nft-indexer.js +13 -4
- package/docs/EXAMPLES.md +38 -0
- package/docs/PROJECT_SPEC.md +122 -0
- package/docs/README.md +38 -0
- package/package.json +1 -1
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
|
@@ -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
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
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
|
package/docs/PROJECT_SPEC.md
CHANGED
|
@@ -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
|