@feralfile/cli 2.10.1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +31 -23
  2. package/config.json.example +6 -1
  3. package/dist/index.js +10 -2
  4. package/dist/package.json +5 -2
  5. package/dist/src/commands/build.js +40 -0
  6. package/dist/src/commands/config.js +1 -0
  7. package/dist/src/commands/device.js +85 -40
  8. package/dist/src/commands/enrich.js +18 -23
  9. package/dist/src/commands/find.js +144 -106
  10. package/dist/src/commands/helpers/device-control.js +937 -0
  11. package/dist/src/commands/helpers/hidden-alias-help.js +37 -0
  12. package/dist/src/commands/helpers/playlist-display.js +2 -15
  13. package/dist/src/commands/helpers/prompt.js +85 -10
  14. package/dist/src/commands/play.js +162 -84
  15. package/dist/src/commands/report.js +251 -0
  16. package/dist/src/commands/setup.js +82 -7
  17. package/dist/src/commands/status.js +4 -1
  18. package/dist/src/config.js +256 -6
  19. package/dist/src/main.js +14 -5
  20. package/dist/src/utilities/control/codec.js +578 -0
  21. package/dist/src/utilities/control/control-errors.js +157 -0
  22. package/dist/src/utilities/control/controller-store.js +964 -0
  23. package/dist/src/utilities/control/jwk.js +141 -0
  24. package/dist/src/utilities/control/pairing-channel.js +52 -0
  25. package/dist/src/utilities/control/pairing.js +416 -0
  26. package/dist/src/utilities/control/registration.js +391 -0
  27. package/dist/src/utilities/control/relay-pairing-channel.js +191 -0
  28. package/dist/src/utilities/control/secure-transport.js +850 -0
  29. package/dist/src/utilities/control/strict-json.js +376 -0
  30. package/dist/src/utilities/device-lookup.js +36 -11
  31. package/dist/src/utilities/device-network.js +129 -0
  32. package/dist/src/utilities/device-upsert.js +1 -1
  33. package/dist/src/utilities/feral-file-artwork.js +2 -2
  34. package/dist/src/utilities/ff1-compatibility.js +87 -11
  35. package/dist/src/utilities/ff1-device.js +161 -78
  36. package/dist/src/utilities/ff1-relayer.js +34 -14
  37. package/dist/src/utilities/handoff-client.js +23 -9
  38. package/dist/src/utilities/index.js +270 -259
  39. package/dist/src/utilities/media-url.js +117 -0
  40. package/dist/src/utilities/neort-marketplace.js +3 -3
  41. package/dist/src/utilities/nft-indexer.js +12 -376
  42. package/dist/src/utilities/playlist-builder.js +11 -2
  43. package/dist/src/utilities/playlist-cast.js +6 -6
  44. package/dist/src/utilities/playlist-source.js +223 -34
  45. package/dist/src/utilities/raster-client.js +101 -14
  46. package/dist/src/utilities/relay-client.js +774 -0
  47. package/dist/src/utilities/ssh-access.js +192 -58
  48. package/dist/src/utilities/support-report.js +511 -0
  49. package/dist/src/utilities/temporary-channel.js +139 -0
  50. package/dist/src/utilities/token-item.js +428 -0
  51. package/dist/src/utilities/token-source-contract.js +55 -0
  52. package/dist/src/utilities/token-source-indexer.js +99 -0
  53. package/dist/src/utilities/token-source-raster.js +378 -0
  54. package/dist/src/utilities/token-source.js +48 -0
  55. package/docs/CONFIGURATION.md +331 -28
  56. package/docs/EXAMPLES.md +33 -33
  57. package/docs/PROJECT_SPEC.md +55 -36
  58. package/docs/README.md +145 -43
  59. package/package.json +5 -2
@@ -0,0 +1,378 @@
1
+ "use strict";
2
+ /**
3
+ * Raster token source: the opt-in provider under evaluation (feral-file#3576).
4
+ *
5
+ * Reads token rows from Raster's public GraphQL API and maps each one to the
6
+ * vendor-neutral StandardToken, which token-item.ts turns into a DP-1 item
7
+ * with the same code the indexer path uses. Item ids, provenance, inline
8
+ * manifests and timing therefore come out identical in shape to the indexer's.
9
+ *
10
+ * What Raster cannot do, and how this provider answers instead of guessing:
11
+ * - no contract listing query: listTokensByContract throws a capability
12
+ * error rather than inventing a probe;
13
+ * - a token it does not cover, or a burned one, comes back null: reported
14
+ * as a failure, never filled from another vendor (one vendor per run);
15
+ * - Feral File metadata is a snapshot: bare ipfs:// and a retired CDN host
16
+ * are fixed up in media-url.ts;
17
+ * - `media.contentType` is a category, not a MIME type: rasterTimingMime
18
+ * turns it into a timing hint that never reaches the document.
19
+ */
20
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
21
+ if (k2 === undefined) k2 = k;
22
+ var desc = Object.getOwnPropertyDescriptor(m, k);
23
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
24
+ desc = { enumerable: true, get: function() { return m[k]; } };
25
+ }
26
+ Object.defineProperty(o, k2, desc);
27
+ }) : (function(o, m, k, k2) {
28
+ if (k2 === undefined) k2 = k;
29
+ o[k2] = m[k];
30
+ }));
31
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
32
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
33
+ }) : function(o, v) {
34
+ o["default"] = v;
35
+ });
36
+ var __importStar = (this && this.__importStar) || (function () {
37
+ var ownKeys = function(o) {
38
+ ownKeys = Object.getOwnPropertyNames || function (o) {
39
+ var ar = [];
40
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
41
+ return ar;
42
+ };
43
+ return ownKeys(o);
44
+ };
45
+ return function (mod) {
46
+ if (mod && mod.__esModule) return mod;
47
+ var result = {};
48
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
49
+ __setModuleDefault(result, mod);
50
+ return result;
51
+ };
52
+ })();
53
+ Object.defineProperty(exports, "__esModule", { value: true });
54
+ exports.rasterTokenStandard = rasterTokenStandard;
55
+ exports.rasterTokenName = rasterTokenName;
56
+ exports.rasterTimingMime = rasterTimingMime;
57
+ exports.rasterTokenToStandardToken = rasterTokenToStandardToken;
58
+ exports.itemsFromRasterTokens = itemsFromRasterTokens;
59
+ exports.createRasterTokenSource = createRasterTokenSource;
60
+ const viem_1 = require("viem");
61
+ const logger = __importStar(require("../logger"));
62
+ const raster_client_1 = require("./raster-client");
63
+ const media_url_1 = require("./media-url");
64
+ const token_item_1 = require("./token-item");
65
+ const token_source_contract_1 = require("./token-source-contract");
66
+ /** CAIP-2 chains ff-cli builds items for. Others are skipped and counted. */
67
+ const CAIP_TO_CHAIN = {
68
+ 'eip155:1': 'ethereum',
69
+ 'tezos:NetXdQprcVkpaWU': 'tezos',
70
+ };
71
+ /** Raster standards DP-1 can name; others become `other` (see rasterTokenStandard). */
72
+ const RASTER_STANDARDS = {
73
+ ERC721: 'erc721',
74
+ ERC1155: 'erc1155',
75
+ FA2: 'fa2',
76
+ };
77
+ /** Page size for the wallet walk. 100 matches listArtistArtworks; 200 was probed fine. */
78
+ const OWNED_PAGE_SIZE = 100;
79
+ /** NOT_ON_RASTER explains a null `tokenByRef` in the user's terms. */
80
+ const NOT_ON_RASTER = 'Raster has no record of this token (not indexed, burned, or nonexistent)';
81
+ /**
82
+ * rasterTokenStandard maps Raster's standard enum to a DP-1 standard.
83
+ *
84
+ * CRYPTOPUNKS and ORDINAL_INSCRIPTION have no DP-1 name, so they are `other`;
85
+ * falling through to detection would call a punk `erc721`, which it is not.
86
+ * A missing standard uses the caller's assertion, then detection.
87
+ */
88
+ function rasterTokenStandard(raw, chain, contractAddress, asserted) {
89
+ if (raw) {
90
+ return RASTER_STANDARDS[raw.toUpperCase()] ?? 'other';
91
+ }
92
+ return (0, token_item_1.resolveTokenStandard)(chain, contractAddress, asserted);
93
+ }
94
+ /**
95
+ * rasterTokenName picks the display title.
96
+ *
97
+ * Raster's GraphQL `Token.name` is empty for Art Blocks tokens (a known Raster
98
+ * bug, feral-file#3546). Art Blocks token ids are project * 1,000,000 + mint,
99
+ * so "<series> #<mint>" reproduces the title Art Blocks itself publishes,
100
+ * e.g. "Fidenza #629".
101
+ */
102
+ function rasterTokenName(token) {
103
+ const name = (token.name ?? '').trim();
104
+ if (name) {
105
+ return name;
106
+ }
107
+ const artwork = token.artworks[0];
108
+ if (artwork?.platform?.id === 'artblocks' && /^\d+$/.test(token.tokenId)) {
109
+ return `${artwork.title} #${BigInt(token.tokenId) % 1000000n}`;
110
+ }
111
+ return artwork?.title || `Token #${token.tokenId}`;
112
+ }
113
+ /**
114
+ * rasterTimingMime turns Raster's media category into a timing hint.
115
+ *
116
+ * The value only steers DP-1 §4.1 timing (video plays its length, web works
117
+ * get the generative duration, the rest the static duration); it is never
118
+ * written into the playlist. `previewType` describes how Raster encoded the
119
+ * preview, not the work (a web app's preview is an image), so it is consulted
120
+ * only when it says video. Empty means unknown, and the URL extension decides,
121
+ * which is the indexer path's behaviour for extensionless URLs too.
122
+ */
123
+ function rasterTimingMime(media) {
124
+ switch ((media?.contentType ?? '').toLowerCase()) {
125
+ case 'video':
126
+ return 'video/*';
127
+ case 'webapp':
128
+ return 'text/html';
129
+ case 'gif':
130
+ return 'image/gif';
131
+ case 'svg':
132
+ return 'image/svg+xml';
133
+ case 'image':
134
+ return 'image/*';
135
+ }
136
+ return (media?.previewType ?? '').toLowerCase().startsWith('video/') ? 'video/*' : '';
137
+ }
138
+ /**
139
+ * RASTER_STILL is deliberately empty: Raster items carry no still image.
140
+ *
141
+ * Raster's preview renditions (`bits.raster.art/.../7200.avif`) load for a
142
+ * browser user agent but answer 403 to curl and to the Feral File app's
143
+ * default `dart:io` client (verified 2026-10-21), and AVIF decoding outside
144
+ * browsers is not guaranteed. A still lands in `thumbnails.default` of a
145
+ * manifest the curator signs, so a URL that is dead for some readers is worse
146
+ * than none. Set a real rendition here only once Raster confirms access that
147
+ * does not depend on the user agent, and record that evidence here.
148
+ */
149
+ const RASTER_STILL = '';
150
+ /**
151
+ * canonicalContract returns the address as the indexer recorded it.
152
+ *
153
+ * Raster lowercases EVM addresses; the indexer returned them checksummed, and
154
+ * the DP-1 item id is sha256 of the address. Checksumming keeps ids and
155
+ * provenance identical across vendors. Tezos addresses are case-sensitive
156
+ * Base58 and Raster keeps them as written.
157
+ */
158
+ function canonicalContract(chain, address) {
159
+ if (chain !== 'ethereum') {
160
+ return address;
161
+ }
162
+ try {
163
+ return (0, viem_1.getAddress)(address);
164
+ }
165
+ catch {
166
+ return address;
167
+ }
168
+ }
169
+ /**
170
+ * rasterTokenToStandardToken maps one Raster row to the vendor-neutral token.
171
+ *
172
+ * @param token - Raster row selected through TOKEN_FIELDS
173
+ * @param gateways - IPFS gateways for bare ipfs:// sources
174
+ * @param requested - The coordinate asked for, when there was one
175
+ * @returns TokenData ready for convertToDP1Item
176
+ */
177
+ function rasterTokenToStandardToken(token, gateways, requested) {
178
+ const chain = CAIP_TO_CHAIN[token.chainId];
179
+ const rawAddress = token.contractAddress ?? requested?.contractAddress;
180
+ if (!chain || !rawAddress) {
181
+ return { success: false, error: `Unsupported chain ${token.chainId}` };
182
+ }
183
+ const contractAddress = canonicalContract(chain, rawAddress);
184
+ const artwork = token.artworks[0];
185
+ const platformId = artwork?.platform?.id ?? null;
186
+ const name = rasterTokenName(token);
187
+ const description = (token.description || artwork?.description || '').trim();
188
+ const artistName = (token.artists[0] ?? artwork?.artists?.[0])?.name ?? '';
189
+ const sourceUrl = (0, media_url_1.fixUpMediaUrl)(token.media?.contentUrl || token.media?.normalizedContentUrl, platformId, gateways);
190
+ return {
191
+ success: true,
192
+ token: {
193
+ chain,
194
+ caip2Chain: token.chainId,
195
+ contractAddress,
196
+ tokenId: token.tokenId,
197
+ standard: rasterTokenStandard(token.tokenStandard, chain, contractAddress, requested?.standard),
198
+ name,
199
+ description,
200
+ image: {
201
+ url: sourceUrl,
202
+ mimeType: rasterTimingMime(token.media),
203
+ thumbnail: RASTER_STILL,
204
+ },
205
+ metadata: { attributes: [], artistName },
206
+ collection: { name: artwork?.title || name, description },
207
+ burned: false,
208
+ },
209
+ };
210
+ }
211
+ /**
212
+ * itemsFromRasterTokens builds results from rows already in hand.
213
+ *
214
+ * Used by the series path of `find`, whose `artwork.tokens` page already
215
+ * carries every field, and by the wallet walk, so neither pays a second
216
+ * round trip.
217
+ */
218
+ function itemsFromRasterTokens(entries, gateways, duration) {
219
+ return entries.map(({ token, requested }) => {
220
+ const tokenData = rasterTokenToStandardToken(token, gateways, requested);
221
+ const coordinate = requested ?? coordinateOf(tokenData, token);
222
+ if (!tokenData.success) {
223
+ return { success: false, coordinate, error: tokenData.error ?? 'Unmappable token' };
224
+ }
225
+ const converted = (0, token_item_1.convertToDP1Item)(tokenData, duration);
226
+ if (!converted.success || !converted.item) {
227
+ return { success: false, coordinate, error: converted.error ?? 'Item build failed' };
228
+ }
229
+ return { success: true, coordinate, item: converted.item, still: converted.still ?? '' };
230
+ });
231
+ }
232
+ /** coordinateOf names the coordinate a row describes when nobody asked for it. */
233
+ function coordinateOf(tokenData, token) {
234
+ return {
235
+ chain: tokenData.token?.chain ?? token.chainId,
236
+ contractAddress: tokenData.token?.contractAddress ?? token.contractAddress ?? '',
237
+ tokenId: token.tokenId,
238
+ };
239
+ }
240
+ /**
241
+ * normalizeChain maps a coordinate's chain to Raster's vocabulary, or null.
242
+ *
243
+ * The address prefix outranks the caller's chain name: `KT…` is Tezos and
244
+ * `0x…` is EVM whatever the caller said, mirroring the auto-correction the
245
+ * indexer path applied. Only Ethereum and Tezos mainnet are supported.
246
+ */
247
+ function normalizeChain(coord) {
248
+ if (coord.contractAddress.startsWith('KT')) {
249
+ return 'tezos';
250
+ }
251
+ if (coord.contractAddress.startsWith('0x')) {
252
+ const chain = coord.chain.toLowerCase();
253
+ return chain === 'ethereum' || chain === 'eth' || chain === 'evm' ? 'ethereum' : null;
254
+ }
255
+ return null;
256
+ }
257
+ /** reportProgress calls a progress callback, which must never break a run. */
258
+ function reportProgress(opts, done, total) {
259
+ try {
260
+ opts.onProgress?.(done, total);
261
+ }
262
+ catch {
263
+ /* progress display is best-effort */
264
+ }
265
+ }
266
+ /** DECIMAL_TOKEN_ID is the only token id shape Raster's BigInt scalar accepts. */
267
+ const DECIMAL_TOKEN_ID = /^\d+$/;
268
+ /**
269
+ * rejectionFor explains why a coordinate cannot be sent to Raster, or ''.
270
+ *
271
+ * Checked before batching because Raster validates every variable of a query
272
+ * together: one non-decimal token id (`0x1f`, `#12`, a typo) rejects the whole
273
+ * request, which would fail up to 39 valid neighbours with an unrelated error.
274
+ */
275
+ function rejectionFor(coord) {
276
+ if (normalizeChain(coord) === null) {
277
+ return `Unsupported chain "${coord.chain}" (Ethereum and Tezos mainnet only)`;
278
+ }
279
+ if (!DECIMAL_TOKEN_ID.test(String(coord.tokenId ?? '').trim())) {
280
+ return `Invalid token id "${coord.tokenId}" (expected a decimal integer)`;
281
+ }
282
+ return '';
283
+ }
284
+ /**
285
+ * resolveBatch looks up one batch and turns each answer into a result.
286
+ * Coordinates that cannot be sent fail on their own; a failed request fails
287
+ * every coordinate it carried with the same reason.
288
+ */
289
+ async function resolveBatch(batch, gateways, duration) {
290
+ const lookups = batch.filter((coord) => rejectionFor(coord) === '');
291
+ let rows;
292
+ try {
293
+ rows = await (0, raster_client_1.fetchTokensByRef)(lookups.map((coord) => ({
294
+ chain: normalizeChain(coord),
295
+ contractAddress: coord.contractAddress,
296
+ tokenId: String(coord.tokenId).trim(),
297
+ })));
298
+ }
299
+ catch (error) {
300
+ const message = error instanceof Error ? error.message : String(error);
301
+ return batch.map((coordinate) => {
302
+ const rejection = rejectionFor(coordinate);
303
+ return { success: false, coordinate, error: rejection || message };
304
+ });
305
+ }
306
+ const byCoord = new Map(lookups.map((coord, i) => [coord, rows[i]]));
307
+ return batch.map((coordinate) => resolveOne(coordinate, byCoord, gateways, duration));
308
+ }
309
+ /** resolveOne turns one coordinate's row (or its absence) into a result. */
310
+ function resolveOne(coordinate, rows, gateways, duration) {
311
+ const rejection = rejectionFor(coordinate);
312
+ if (rejection) {
313
+ return { success: false, coordinate, error: rejection };
314
+ }
315
+ const row = rows.get(coordinate);
316
+ if (!row) {
317
+ return { success: false, coordinate, error: NOT_ON_RASTER };
318
+ }
319
+ return itemsFromRasterTokens([{ token: row, requested: coordinate }], gateways, duration)[0];
320
+ }
321
+ /**
322
+ * walkOwnedTokens pages through an address's holdings until `limit` supported
323
+ * tokens are collected or the walk ends. Any page failure throws: a silently
324
+ * truncated wallet would look like a complete one.
325
+ */
326
+ async function walkOwnedTokens(address, limit) {
327
+ const tokens = [];
328
+ let skippedUnsupported = 0;
329
+ let after;
330
+ do {
331
+ const page = await (0, raster_client_1.fetchTokensOwned)(address, { first: OWNED_PAGE_SIZE, after });
332
+ for (const token of page.tokens) {
333
+ if (!CAIP_TO_CHAIN[token.chainId] || !token.contractAddress) {
334
+ skippedUnsupported += 1;
335
+ }
336
+ else if (limit === 'all' || tokens.length < limit) {
337
+ tokens.push(token);
338
+ }
339
+ }
340
+ after = page.nextCursor ?? undefined;
341
+ } while (after && (limit === 'all' || tokens.length < limit));
342
+ return { tokens, skippedUnsupported };
343
+ }
344
+ /**
345
+ * createRasterTokenSource builds the Raster provider.
346
+ *
347
+ * Batches run one after another: 40 tokens per request already turns the
348
+ * ff-cli#132 case (43 tokens, ~10 minutes on the indexer) into two requests,
349
+ * and sequential batches keep load on an unmetered beta API predictable.
350
+ *
351
+ * @param gateways - Resolved IPFS gateways from config
352
+ */
353
+ function createRasterTokenSource(gateways) {
354
+ return {
355
+ name: 'raster',
356
+ async resolveTokens(coords, opts = {}) {
357
+ const results = [];
358
+ for (let i = 0; i < coords.length; i += raster_client_1.RASTER_TOKEN_BATCH_SIZE) {
359
+ const batch = coords.slice(i, i + raster_client_1.RASTER_TOKEN_BATCH_SIZE);
360
+ results.push(...(await resolveBatch(batch, gateways, opts.duration)));
361
+ reportProgress(opts, results.length, coords.length);
362
+ }
363
+ logger.debug(`[Raster] Resolved ${results.filter((r) => r.success).length}/${coords.length}`);
364
+ return results;
365
+ },
366
+ async listTokensByOwner(address, opts) {
367
+ const walked = await walkOwnedTokens(address, opts.limit);
368
+ const entries = walked.tokens.map((token) => ({ token }));
369
+ return {
370
+ results: itemsFromRasterTokens(entries, gateways, opts.duration),
371
+ skippedUnsupported: walked.skippedUnsupported,
372
+ };
373
+ },
374
+ async listTokensByContract() {
375
+ throw new token_source_contract_1.TokenSourceCapabilityError(token_source_contract_1.RASTER_CANNOT_LIST_CONTRACT);
376
+ },
377
+ };
378
+ }
@@ -0,0 +1,48 @@
1
+ "use strict";
2
+ /**
3
+ * Token source factory: the one place that decides which vendor answers.
4
+ *
5
+ * Commands call getTokenSource() and see only the TokenSource contract.
6
+ * The Feral File indexer (ff-indexer-v2) is the default. Raster is opt-in with
7
+ * `tokenSource: "raster"` (or FF_TOKEN_SOURCE=raster) while it is evaluated:
8
+ * its data still has open bugs (feral-file#3546), so it is not the default
9
+ * until they are fixed (owner decision, 2026-10-22). There is no fallback
10
+ * between providers: one vendor answers a whole run (feral-file#3576).
11
+ */
12
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
13
+ if (k2 === undefined) k2 = k;
14
+ var desc = Object.getOwnPropertyDescriptor(m, k);
15
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
16
+ desc = { enumerable: true, get: function() { return m[k]; } };
17
+ }
18
+ Object.defineProperty(o, k2, desc);
19
+ }) : (function(o, m, k, k2) {
20
+ if (k2 === undefined) k2 = k;
21
+ o[k2] = m[k];
22
+ }));
23
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
24
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
25
+ };
26
+ Object.defineProperty(exports, "__esModule", { value: true });
27
+ exports.createTokenSource = createTokenSource;
28
+ exports.getTokenSource = getTokenSource;
29
+ const config_1 = require("../config");
30
+ const token_source_indexer_1 = require("./token-source-indexer");
31
+ const token_source_raster_1 = require("./token-source-raster");
32
+ __exportStar(require("./token-source-contract"), exports);
33
+ /**
34
+ * createTokenSource builds the provider a configuration selects.
35
+ *
36
+ * @param config - Resolved token source configuration
37
+ * @returns The selected provider
38
+ */
39
+ function createTokenSource(config) {
40
+ if (config.source === 'raster') {
41
+ return (0, token_source_raster_1.createRasterTokenSource)(config.ipfsGateways);
42
+ }
43
+ return (0, token_source_indexer_1.createIndexerTokenSource)();
44
+ }
45
+ /** getTokenSource builds the provider from the current configuration. */
46
+ function getTokenSource() {
47
+ return createTokenSource((0, config_1.getTokenSourceConfig)());
48
+ }