@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
@@ -11,6 +11,8 @@ exports.getPlaylistConfig = getPlaylistConfig;
11
11
  exports.getFeedConfig = getFeedConfig;
12
12
  exports.getFF1DeviceConfig = getFF1DeviceConfig;
13
13
  exports.getFF1RelayerConfig = getFF1RelayerConfig;
14
+ exports.getSupportLogsConfig = getSupportLogsConfig;
15
+ exports.getTokenSourceConfig = getTokenSourceConfig;
14
16
  exports.validateConfig = validateConfig;
15
17
  exports.createSampleConfig = createSampleConfig;
16
18
  const fs_1 = __importDefault(require("fs"));
@@ -18,6 +20,45 @@ const path_1 = __importDefault(require("path"));
18
20
  const os_1 = __importDefault(require("os"));
19
21
  const playlist_signing_role_1 = require("./utilities/playlist-signing-role");
20
22
  const config_placeholders_1 = require("./utilities/config-placeholders");
23
+ /**
24
+ * The feed server used when neither config.json nor FEED_BASE_URLS names one. It replaced
25
+ * dp1-feed-operator-api-prod.autonomy-system.workers.dev, a deleted worker whose zone belongs to
26
+ * an account being retired: a default must never name a host someone else could register.
27
+ * DEFAULT_FF1_RELAYER_URL below is in the same zone but still live; the ff-cli release after
28
+ * the legacy relayer shutdown turns it off (ffos-user docs/ff1-control-v1.md §15.4).
29
+ */
30
+ const DEFAULT_FEED_BASE_URL = 'https://feed.feralfile.com/api/v1';
31
+ /**
32
+ * The deleted worker ff-cli 3.0.0 named. Its `setup` and `config init` copied it into every
33
+ * config.json they wrote, and an explicit feed wins over the default, so a configured URL on
34
+ * this host is read as feed.feralfile.com (path kept) instead: same operator, same API.
35
+ */
36
+ const RETIRED_FEED_HOST = 'dp1-feed-operator-api-prod.autonomy-system.workers.dev';
37
+ const RETIRED_FEED_REPLACEMENT_ORIGIN = 'https://feed.feralfile.com';
38
+ let retiredFeedNoted = false;
39
+ /**
40
+ * replaceRetiredFeedUrl maps a feed URL on RETIRED_FEED_HOST to feed.feralfile.com, keeping its
41
+ * path, and notes it once per run on stderr (stdout may be a script's JSON). Any other URL,
42
+ * including an unparseable one, is returned unchanged.
43
+ */
44
+ function replaceRetiredFeedUrl(url) {
45
+ let parsed;
46
+ try {
47
+ parsed = new URL(url);
48
+ }
49
+ catch {
50
+ return url;
51
+ }
52
+ if (parsed.hostname.toLowerCase() !== RETIRED_FEED_HOST) {
53
+ return url;
54
+ }
55
+ const replaced = `${RETIRED_FEED_REPLACEMENT_ORIGIN}${parsed.pathname.replace(/\/$/, '')}${parsed.search}`;
56
+ if (!retiredFeedNoted) {
57
+ retiredFeedNoted = true;
58
+ console.error(`Note: ${url} is a retired feed server; using ${replaced}. Update the feed in config.json or FEED_BASE_URLS to stop this note.`);
59
+ }
60
+ return replaced;
61
+ }
21
62
  function getConfigPaths() {
22
63
  const localPath = path_1.default.join(process.cwd(), 'config.json');
23
64
  const configBase = process.env.XDG_CONFIG_HOME || path_1.default.join(os_1.default.homedir(), '.config');
@@ -43,7 +84,7 @@ function loadConfig() {
43
84
  feed: {
44
85
  baseURLs: process.env.FEED_BASE_URLS
45
86
  ? process.env.FEED_BASE_URLS.split(',')
46
- : ['https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1'],
87
+ : [DEFAULT_FEED_BASE_URL],
47
88
  },
48
89
  };
49
90
  // Try to load config.json if it exists
@@ -188,10 +229,13 @@ function getFeedConfig() {
188
229
  const config = getConfig();
189
230
  // Check for new feedServers format first
190
231
  if (config.feedServers && Array.isArray(config.feedServers) && config.feedServers.length > 0) {
191
- const baseURLs = config.feedServers.map((server) => server.baseUrl);
232
+ const servers = config.feedServers.map((server) => ({
233
+ ...server,
234
+ baseUrl: replaceRetiredFeedUrl(server.baseUrl),
235
+ }));
192
236
  return {
193
- baseURLs,
194
- servers: config.feedServers,
237
+ baseURLs: servers.map((server) => server.baseUrl),
238
+ servers,
195
239
  };
196
240
  }
197
241
  // Fall back to legacy feed format
@@ -206,10 +250,10 @@ function getFeedConfig() {
206
250
  }
207
251
  else {
208
252
  // Default feed URL
209
- urls = ['https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1'];
253
+ urls = [DEFAULT_FEED_BASE_URL];
210
254
  }
211
255
  return {
212
- baseURLs: urls,
256
+ baseURLs: urls.map(replaceRetiredFeedUrl),
213
257
  };
214
258
  }
215
259
  /**
@@ -230,6 +274,12 @@ function getFF1DeviceConfig() {
230
274
  };
231
275
  }
232
276
  const DEFAULT_FF1_RELAYER_URL = 'https://tv-cast-coordination.autonomy-system.workers.dev';
277
+ /**
278
+ * The production relay v2 origin. Relay v2 runs on its own Worker, not on the
279
+ * legacy relayer's origin, and must keep working after the legacy host is
280
+ * retired (ffos-user docs/ff1-control-v1.md §15.3, §15.4).
281
+ */
282
+ const DEFAULT_FF1_RELAY_V2_ENDPOINT = 'wss://relayer.feralfile.io';
233
283
  /**
234
284
  * Get the FF1 relayer endpoint using repository config priority.
235
285
  *
@@ -270,12 +320,200 @@ function resolveFF1RelayerConfig(config) {
270
320
  if (!['http:', 'https:'].includes(parsed.protocol)) {
271
321
  throw new Error('ff1Relayer.baseUrl must be a valid HTTP(S) URL');
272
322
  }
323
+ const v2 = resolveV2Endpoint(fields.v2Endpoint, parsed);
273
324
  const apiKey = (fields.apiKey || process.env.FF1_RELAYER_API_KEY)?.trim();
274
325
  return {
275
326
  baseUrl,
276
327
  apiKey: apiKey || undefined,
328
+ ...(v2.endpoint ? { v2Endpoint: v2.endpoint } : {}),
329
+ ...(v2.endpoint && v2.explicit ? { v2Explicit: true } : {}),
277
330
  };
278
331
  }
332
+ const DEFAULT_SUPPORT_LOGS_URL = 'https://support-logs.feralfile.com';
333
+ /**
334
+ * getSupportLogsConfig returns the support-logs origin `ff-cli report` posts to.
335
+ *
336
+ * `config.json` (`supportLogs.baseUrl`) wins over FF_SUPPORT_LOGS_URL, which
337
+ * wins over the production default, the same order as `ff1Relayer.baseUrl`.
338
+ * The override is for tests and staging. It does not move the Art Computer's
339
+ * log upload: FF OS posts its logs to the support-logs its firmware names.
340
+ */
341
+ function getSupportLogsConfig() {
342
+ return resolveSupportLogsConfig(getConfig());
343
+ }
344
+ /** Resolve and validate the effective support-logs configuration. */
345
+ function resolveSupportLogsConfig(config) {
346
+ const configured = config.supportLogs;
347
+ if (configured !== undefined &&
348
+ (configured === null || typeof configured !== 'object' || Array.isArray(configured))) {
349
+ throw new Error('supportLogs must be an object');
350
+ }
351
+ const baseUrl = configured?.baseUrl;
352
+ if (baseUrl !== undefined && typeof baseUrl !== 'string') {
353
+ throw new Error('supportLogs.baseUrl must be a string');
354
+ }
355
+ const value = (baseUrl ||
356
+ process.env.FF_SUPPORT_LOGS_URL ||
357
+ DEFAULT_SUPPORT_LOGS_URL)
358
+ .trim()
359
+ .replace(/\/+$/, '');
360
+ let parsed;
361
+ try {
362
+ parsed = new URL(value);
363
+ }
364
+ catch {
365
+ throw new Error('supportLogs.baseUrl must be a valid HTTP(S) URL');
366
+ }
367
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
368
+ throw new Error('supportLogs.baseUrl must be a valid HTTP(S) URL');
369
+ }
370
+ return { baseUrl: value };
371
+ }
372
+ const TOKEN_SOURCES = ['raster', 'indexer'];
373
+ const DEFAULT_TOKEN_SOURCE = 'indexer';
374
+ const DEFAULT_IPFS_GATEWAY_FERAL_FILE = 'https://ipfs.feralfile.com/ipfs/';
375
+ const DEFAULT_IPFS_GATEWAY = 'https://ipfs.filebase.io/ipfs/';
376
+ /**
377
+ * Get the token source selection using repository config priority.
378
+ *
379
+ * `config.json` wins over environment variables, which win over the defaults.
380
+ * The Feral File indexer is the default vendor; `raster` is opt-in while
381
+ * Raster's data bugs (feral-file#3546) are open. There is no fallback between
382
+ * the two at runtime: one vendor answers a whole run.
383
+ */
384
+ function getTokenSourceConfig() {
385
+ return resolveTokenSourceConfig(getConfig());
386
+ }
387
+ /** Resolve and validate the effective token source configuration. */
388
+ function resolveTokenSourceConfig(config) {
389
+ const source = resolveTokenSourceName(config);
390
+ const gateways = resolveIpfsGatewayFields(config);
391
+ return {
392
+ source,
393
+ ipfsGateways: {
394
+ feralFile: normalizeIpfsGateway(gateways.feralFile ||
395
+ process.env.IPFS_GATEWAY_FERAL_FILE ||
396
+ DEFAULT_IPFS_GATEWAY_FERAL_FILE, 'ipfsGateways.feralFile'),
397
+ default: normalizeIpfsGateway(gateways.default || process.env.IPFS_GATEWAY_DEFAULT || DEFAULT_IPFS_GATEWAY, 'ipfsGateways.default'),
398
+ },
399
+ };
400
+ }
401
+ /** resolveTokenSourceName picks the vendor name, rejecting anything outside TOKEN_SOURCES. */
402
+ function resolveTokenSourceName(config) {
403
+ const configured = config.tokenSource;
404
+ if (configured !== undefined && typeof configured !== 'string') {
405
+ throw new Error(`tokenSource must be one of: ${TOKEN_SOURCES.join(', ')}`);
406
+ }
407
+ const candidate = (configured || process.env.FF_TOKEN_SOURCE || '')
408
+ .trim()
409
+ .toLowerCase();
410
+ if (!candidate) {
411
+ return DEFAULT_TOKEN_SOURCE;
412
+ }
413
+ if (!TOKEN_SOURCES.includes(candidate)) {
414
+ throw new Error(`tokenSource must be one of: ${TOKEN_SOURCES.join(', ')}`);
415
+ }
416
+ return candidate;
417
+ }
418
+ /** resolveIpfsGatewayFields type-checks the `ipfsGateways` block without resolving defaults. */
419
+ function resolveIpfsGatewayFields(config) {
420
+ const configured = config.ipfsGateways;
421
+ if (configured === undefined) {
422
+ return {};
423
+ }
424
+ if (configured === null || typeof configured !== 'object' || Array.isArray(configured)) {
425
+ throw new Error('ipfsGateways must be an object');
426
+ }
427
+ const fields = configured;
428
+ for (const key of ['feralFile', 'default']) {
429
+ if (fields[key] !== undefined && typeof fields[key] !== 'string') {
430
+ throw new Error(`ipfsGateways.${key} must be a string`);
431
+ }
432
+ }
433
+ return { feralFile: fields.feralFile, default: fields.default };
434
+ }
435
+ /**
436
+ * normalizeIpfsGateway validates a gateway and returns it in `https://host/ipfs/` form.
437
+ *
438
+ * A bare origin is accepted because that is how gateways are usually quoted;
439
+ * the `/ipfs/` path is what a CID is appended to, so it is added when absent
440
+ * and a trailing slash is guaranteed so callers can concatenate.
441
+ */
442
+ function normalizeIpfsGateway(value, label) {
443
+ let parsed;
444
+ try {
445
+ parsed = new URL(value.trim());
446
+ }
447
+ catch {
448
+ throw new Error(`${label} must be a valid HTTP(S) URL`);
449
+ }
450
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
451
+ throw new Error(`${label} must be a valid HTTP(S) URL`);
452
+ }
453
+ const path = parsed.pathname.replace(/\/+$/, '');
454
+ parsed.pathname = path.endsWith('/ipfs') ? `${path}/` : `${path}/ipfs/`;
455
+ parsed.search = '';
456
+ parsed.hash = '';
457
+ return parsed.toString();
458
+ }
459
+ /**
460
+ * resolveV2Endpoint picks the relay v2 origin (ffos-user docs/ff1-control-v1.md
461
+ * §10). An explicit `ff1Relayer.v2Endpoint` or FF1_RELAY_V2_ENDPOINT wins; an
462
+ * empty string turns v2 off. Otherwise the production legacy relayer maps to
463
+ * the production relay v2 Worker (DEFAULT_FF1_RELAY_V2_ENDPOINT), which lives
464
+ * on its own origin. A custom HTTPS legacy relayer (a development Worker that
465
+ * serves both `/api` and `/v2/channels/…`) still uses its own origin. A
466
+ * custom plain-HTTP or path-bearing legacy relayer gets no v2 unless one is
467
+ * configured.
468
+ *
469
+ * The derived default is only a pairing default for commands: during the
470
+ * rollout most FF1s are not on v2, and a stranger's ACK on the device channel
471
+ * would turn a command that the legacy topic could have delivered into an
472
+ * ambiguous one. secure-transport therefore sends commands on a derived
473
+ * endpoint only to an FF1 that advertised `relayV2` in a verified status.
474
+ *
475
+ * @param configured - The `ff1Relayer.v2Endpoint` value, if any
476
+ * @param baseUrl - The validated legacy relayer URL
477
+ * @returns The v2 origin (undefined when v2 is off) and whether it was configured
478
+ * @throws {Error} When a configured endpoint is not an acceptable origin
479
+ */
480
+ function resolveV2Endpoint(configured, baseUrl) {
481
+ const value = configured ?? process.env.FF1_RELAY_V2_ENDPOINT;
482
+ if (value === undefined) {
483
+ if (baseUrl.origin === new URL(DEFAULT_FF1_RELAYER_URL).origin) {
484
+ return { endpoint: DEFAULT_FF1_RELAY_V2_ENDPOINT, explicit: false };
485
+ }
486
+ const bare = !baseUrl.username && !baseUrl.password && !baseUrl.search && !baseUrl.hash;
487
+ const derived = baseUrl.protocol === 'https:' && bare && ['', '/'].includes(baseUrl.pathname);
488
+ return { endpoint: derived ? baseUrl.origin : undefined, explicit: false };
489
+ }
490
+ if (typeof value !== 'string') {
491
+ throw new Error('ff1Relayer.v2Endpoint must be a string');
492
+ }
493
+ if (value.trim() === '') {
494
+ return { explicit: true };
495
+ }
496
+ let endpoint;
497
+ try {
498
+ endpoint = new URL(value);
499
+ }
500
+ catch {
501
+ throw new Error('Invalid ff1Relayer.v2Endpoint');
502
+ }
503
+ const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(endpoint.hostname);
504
+ if (!['https:', 'wss:'].includes(endpoint.protocol) &&
505
+ !(['http:', 'ws:'].includes(endpoint.protocol) && loopback)) {
506
+ throw new Error('ff1Relayer.v2Endpoint requires WSS/HTTPS outside loopback');
507
+ }
508
+ if (endpoint.username ||
509
+ endpoint.password ||
510
+ endpoint.search ||
511
+ endpoint.hash ||
512
+ !['', '/'].includes(endpoint.pathname)) {
513
+ throw new Error('Invalid ff1Relayer.v2Endpoint');
514
+ }
515
+ return { endpoint: value, explicit: true };
516
+ }
279
517
  /**
280
518
  * Validate configuration
281
519
  *
@@ -341,6 +579,18 @@ function validateConfig() {
341
579
  catch (error) {
342
580
  errors.push(error.message);
343
581
  }
582
+ try {
583
+ resolveTokenSourceConfig(config);
584
+ }
585
+ catch (error) {
586
+ errors.push(error.message);
587
+ }
588
+ try {
589
+ resolveSupportLogsConfig(config);
590
+ }
591
+ catch (error) {
592
+ errors.push(error.message);
593
+ }
344
594
  return {
345
595
  valid: errors.length === 0,
346
596
  errors,
package/dist/src/main.js CHANGED
@@ -4,6 +4,7 @@
4
4
  * Builds DP-1 playlists deterministically from structured parameters.
5
5
  */
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.REQUIREMENT_TYPES = void 0;
7
8
  exports.validateRequirements = validateRequirements;
8
9
  exports.applyPlaylistDefaults = applyPlaylistDefaults;
9
10
  exports.buildPlaylistDirect = buildPlaylistDirect;
@@ -18,10 +19,20 @@ process.emitWarning = function (warning, type, ctor) {
18
19
  }
19
20
  return originalEmitWarning.apply(this, [warning, type, ctor]);
20
21
  };
21
- const config_1 = require("./config");
22
22
  // Lazy load utilities to avoid circular dependencies
23
23
  // eslint-disable-next-line @typescript-eslint/no-require-imports
24
24
  const getUtilities = () => require('./utilities');
25
+ /**
26
+ * REQUIREMENT_TYPES lists every requirement `build` accepts, in the order
27
+ * `build --help` documents them. Kept beside validateRequirements so a new
28
+ * branch there and this list change together.
29
+ */
30
+ exports.REQUIREMENT_TYPES = [
31
+ 'build_playlist',
32
+ 'query_address',
33
+ 'fetch_feed',
34
+ 'feral_file_artwork',
35
+ ];
25
36
  /**
26
37
  * Validate and apply constraints to requirements
27
38
  *
@@ -103,7 +114,8 @@ function validateRequirements(requirements) {
103
114
  };
104
115
  }
105
116
  }
106
- throw new Error(`Requirement ${index + 1}: invalid type "${req.type}"`);
117
+ throw new Error(`Requirement ${index + 1}: invalid type "${req.type}". ` +
118
+ `Valid types: ${exports.REQUIREMENT_TYPES.join(', ')} (see ff-cli build --help).`);
107
119
  });
108
120
  }
109
121
  /**
@@ -140,8 +152,5 @@ async function buildPlaylistDirect(params, options = {}) {
140
152
  const requirements = validateRequirements(params.requirements);
141
153
  const playlistSettings = applyPlaylistDefaults(params.playlistSettings);
142
154
  const utilities = getUtilities();
143
- const config = (0, config_1.getConfig)();
144
- // Initialize utilities with config (indexer endpoint, API key, etc.)
145
- utilities.initializeUtilities(config);
146
155
  return await utilities.buildPlaylistDirect({ requirements, playlistSettings }, options);
147
156
  }